Compare commits

...
Author SHA1 Message Date
kristinheandCursor f1afdb03cd fix(memhub): export complete logs from settings
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 23:49:21 +08:00
feitianxiang b0e429b9ae Merge pull request #322 from Tencent-Hunyuan/docs/memhub-1.0.6-changelog
docs(changelog): complete MemHub 1.0.6 notes (build 41)
2026-11-04 20:49:02 +08:00
alvinfeiandClaude Opus 5.5 01faf28942 docs(changelog): MemHub 1.0.6 covers everything merged through build 41
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-04 20:47:19 +08:00
kristinhe1111 fb1b1373ab Merge pull request #321 from Tencent-Hunyuan/chore/memhub-build-41
chore(release): bump MemHub build to 41
2026-11-04 20:39:34 +08:00
kristinheandCursor 2823bd743d chore(release): bump MemHub build to 41
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 20:36:00 +08:00
feitianxiang 837c084be2 Merge pull request #320 from Tencent-Hunyuan/fix/memhub-db-contention
fix(memhub): bound import jobs, keep the write queue off the bulk pool, nothing blocking on the event loop
2026-11-04 20:27:32 +08:00
alvinfeiandClaude Opus 5.5 bf52724461 fix(memhub): bound import jobs, keep the write queue off the bulk pool, nothing blocking on the event loop
Under a long live-sync history, writes stalled behind "database is locked"
refusals, durable leases lapsed, and the App stopped answering for tens of
seconds at a time.

- import_jobs is bounded: finished (completed/cancelled) sync jobs are
  pruned to the newest 20 per (user, source), with their issues and skip
  counts, in bounded batches at startup and every 10 minutes. History jobs
  and every unfinished job are kept.
- Job lookups are SQL (find_jobs / latest_job) instead of reading the whole
  table, served by new indexes in schema migration 63. GET
  /api/v1/import-jobs keeps its shape and order.
- The durable write queue runs claim, settle and maintenance in its own pool
  and lease renewal in a dedicated one, so bulk work cannot starve a lease.
  The single writer and its line are unchanged.
- abandon, resume, enqueue, pause, cancel, shutdown and startup resume make
  their database calls off the loop; the file watcher resolves changed
  paths off the loop; revoke reads and writes its grant off the loop.
- The WAL cadence checkpoint is requested from the write path and run by one
  checkpointer thread instead of inline on the writing thread.
- The durable drain is supervised: an unexpected exit is reported with its
  stack and the drain restarts with capped backoff under a new lease owner.
  memory-processing and memhub status show write_drain and
  write_drain_error. Contained per-job failures are reported with stacks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-04 19:57:26 +08:00
feitianxiang 964d8610f3 Merge pull request #319 from Tencent-Hunyuan/fix/memhub-lite-raw-rag
fix(memhub): hy-memory 2.0.14; Lite search is plain RAG over raw
2026-11-04 19:42:51 +08:00
feitianxiang e1933d3307 Merge pull request #318 from Tencent-Hunyuan/release/hy-memory-2.0.14
release: hy-memory 2.0.14
2026-11-04 19:42:39 +08:00
alvinfeiandClaude Opus 5.5 5fdaf6c280 fix(memhub): Lite search is plain RAG over raw
Lite context search, recall and the Host Bridge return every raw vector
hit above the floor, ranked by score, over active and shadow raws. The
turn view shows the turns containing the query, or else the hit's
closest turn, instead of dropping semantic hits. Pro and ultra raw
search stays active-only.

A new test runs the raw routes in a child interpreter against the
installed hy-memory wheel, and the route-test fake SDK now rejects
search kwargs the pinned release does not accept.

The lite raw-turn page projects only raws newly put on top instead of
re-listing the whole raw layer on every poll.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-04 19:32:12 +08:00
alvinfeiandClaude Opus 5.5 58f2fa884a build(memhub): pin hy-memory 2.0.14
2.0.14 accepts `statuses` on explicit L1_RAW search, which Core has
passed since #316. Covers the same files as the 2.0.13 pin (#285).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-04 19:32:11 +08:00
alvinfeiandClaude Opus 5.5 c0910621c1 release: hy-memory 2.0.14
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-04 17:52:06 +08:00
kristinhe1111 dd13c551c4 Merge pull request #317 from Tencent-Hunyuan/fix/settings-field-width
fix(web): expand settings controls to content width
2026-11-04 17:41:20 +08:00
kristinheandCursor e7d82ee5e8 fix(web): expand settings controls to content width
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 16:54:55 +08:00
kristinhe1111 63e009bf6c Merge pull request #307 from Tencent-Hunyuan/fix/lite-home-saving-copy
fix(memhub): keep home status copy compact
2026-11-04 16:26:27 +08:00
kristinhe1111 d4cfd1bb74 Merge pull request #309 from Tencent-Hunyuan/fix/memhub-durable-drain-liveness
fix(memhub): keep durable write drain alive
2026-11-04 16:26:08 +08:00
kristinhe1111 2822cbf368 Merge pull request #310 from Tencent-Hunyuan/fix/memhub-inline-model-config
fix(memhub): inline model configuration panels
2026-11-04 16:25:47 +08:00
kristinhe1111 8d23347e1a Merge pull request #312 from Tencent-Hunyuan/fix/memory-mode-confirm-copy
fix(memhub): clarify memory mode switch effects
2026-11-04 16:25:22 +08:00
kristinhe1111 8ae0d1889e Merge pull request #315 from Tencent-Hunyuan/fix/ultra-topic-hover-only
fix(memhub): make Ultra topic nodes hover-only
2026-11-04 16:24:58 +08:00
kristinhe1111 2479de22eb Merge pull request #316 from Tencent-Hunyuan/fix/memhub-lite-turn-view
fix(memhub): show Lite raw conversations by turn
2026-11-04 16:24:44 +08:00
kristinheandCursor 265a22d612 fix(memhub): show lite raw conversations by turn
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 16:10:57 +08:00
kristinheandCursor 38ad2292d1 fix(memhub): clarify memory mode switch effects
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 15:58:03 +08:00
kristinheandCursor 413b9033f9 fix(memhub): inline model configuration panels
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 15:49:20 +08:00
kristinheandCursor 0953c5af35 fix(memhub): keep durable write drain alive
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 15:22:29 +08:00
kristinheandCursor 470ea4459a fix(memhub): keep home status copy compact
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 14:53:33 +08:00
96 changed files with 4064 additions and 500 deletions

No files matched your search

+62 -9
View File
@@ -4,13 +4,14 @@ Hy Memory and MemHub are versioned and released independently.
## Unreleased
- MemHub App now exposes the complete memory-mode lifecycle. New installs
explicitly choose lite or pro during onboarding; pro completes only after a
generation model is active, while ultra remains an advanced Settings choice.
Settings shows mode effects, graph backfill, the default 03:00 evolve
schedule and manual evolve. The memory page shows original conversations in
lite and advanced memories in pro/ultra. Existing installs remain pro unless
the user changes them, and embedding maintenance blocks mode/evolve changes.
## Hy Memory 2.0.14 — 2026-11-04
- `search()` / `async_search()` take an optional `statuses` for explicit
L1_RAW search (`layers=["l1_raw"]`, or any search in lite mode), e.g.
`statuses=["active", "shadow"]`, so raw turns a pro extraction already
shadowed can still be searched. Default stays active-only. Raw hits now
report their real `status`. Passing `statuses` to a non-raw search or to
`scene="all"` raises `ValueError` instead of being ignored.
## Hy Memory 2.0.13 — 2026-11-02
@@ -81,11 +82,63 @@ Hy Memory and MemHub are versioned and released independently.
like the other feature switches. The API answer gains `parameters`, and
`pipeline.features` can now carry the threshold as a number. Refs #241.
## MemHub 1.0.6 — 2026-11-02
## MemHub 1.0.6 — 2026-11-04
Build 40, on `hy-memory==2.0.13`. Linux x64/arm64 Runtime and macOS arm64
Build 41, on `hy-memory==2.0.14`. Linux x64/arm64 Runtime and macOS arm64
App and Runtime.
- MemHub App now exposes the complete memory-mode lifecycle. New installs
explicitly choose lite or pro during onboarding; pro completes only after a
generation model is active, while ultra remains an advanced Settings choice.
Settings shows mode effects, graph backfill, the default 03:00 evolve
schedule and manual evolve. The memory page shows original conversations in
lite and advanced memories in pro/ultra. Existing installs remain pro unless
the user changes them, and embedding maintenance blocks mode/evolve changes.
- MemHub pins `hy-memory==2.0.14`. Lite search is plain RAG over raw: the
App's lite search, CLI context search, `/api/v1/recall` and the Host Bridge
(`MemHub_recall`, context search) return every vector hit above the floor,
ranked by score, over active and shadow raws, so history pro already
processed is still found after switching to lite. The turn view no longer
drops a hit whose turns do not literally contain the query; it shows the
hit's closest turn instead. Pro and ultra raw search stays active-only.
Fixes every raw search failing in packaged builds since #316, which passed
`statuses` to an SDK (2.0.13) that did not accept it. The lite raw-turn
page also stops re-listing the whole raw layer on each poll when a live
session only adds new raws.
- MemHub Core no longer stalls writes or freezes the App for tens of seconds
on a machine with a long live-sync history. Finished live-sync import jobs
are pruned to the newest 20 per source (at startup, in bounded batches, and
every 10 minutes); history imports and every unfinished job are kept. Job
lookups are filtered in SQL with new indexes (schema migration 63) instead
of reading the whole table. The durable write queue runs its claims,
settles and lease renewals on its own threads, so import work can no longer
starve a lease. Import abandon, resume and enqueue, and the file watcher's
path attribution, no longer touch the database or the disk on the event
loop, and the WAL cadence checkpoint runs on its own thread instead of on
the writer's. The write drain now restarts itself after an unexpected exit,
reports the failure with its stack, and shows `write_drain` /
`write_drain_error` in memory-processing and `memhub status`.
- Model Center (#293, #295): Settings splits into 界面语言 / 记忆模式 /
模型配置 pages, with separate 记忆整理 (generation) and 记忆搜索
(embedding) lanes. Changing one lane sends only that lane's patch. An
embedding change shows the switch plan, confirmation, progress, cancel and
rollback, and the search model only changes after the rebuilt index is
verified. Model panels open inside the lane that launched them and close
only after Core confirms the profile is active (#310).
- Lite keeps raw conversations (#297): lite raw writes are no longer stopped
by the "pause model sedimentation" switch, the memory page lists every
imported raw conversation by role (by turn since #316), and lite adds a
manual raw input (source `one-memory-manual`) that never calls a
generation model.
- The home page follows the memory mode (#298): lite counts raw
conversations and previews the latest one; pro and ultra read memories.
Pro and ultra now require an active generation model, in both the mode
preview and the switch.
- Onboarding (#304) asks for lite or pro and resumes after a restart (schema
migration 62); pro finishes only once a generation model is active.
- Smaller App fixes: compact home status copy (#307), clearer mode-switch
effects (#312), Ultra topic nodes are hover-only (#315), and settings
controls use the content width (#317).
- Interfaces in 11 languages: English, Simplified and Traditional Chinese,
Japanese, Spanish, German, Portuguese, French, Korean, Russian and
Indonesian. Every interface language, Simplified Chinese included, sets the
+1 -1
View File
@@ -33,7 +33,7 @@ English | [中文](README.zh.md)
## Latest News
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Open-sourced** quantized **FP8** (**[Hy-Memory-30B-A3B-FP8](https://huggingface.co/tencent/Hy-Memory-30B-A3B-FP8)**) and **GGUF** (**[Hy-Memory-30B-A3B-GGUF](https://huggingface.co/tencent/Hy-Memory-30B-A3B-GGUF)**) versions of **Hy-Memory-30B-A3B** on Hugging Face!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-11]**: 📦 **Released [MemHub v1.0.6](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.6)** with interfaces in 11 languages, memory modes (lite / pro / ultra with nightly evolve), safe embedding-model switching, multi-user support for the CLI and API, memory writes that retry themselves and pause while the model provider is down, and more reliable upgrades, on **`hy-memory==2.0.13`**. See the release notes for details.
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-11]**: 📦 **Released [MemHub v1.0.6](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.6)** with interfaces in 11 languages, memory modes (lite / pro / ultra with nightly evolve), safe embedding-model switching, multi-user support for the CLI and API, memory writes that retry themselves and pause while the model provider is down, and more reliable upgrades, on **`hy-memory==2.0.14`**. See the release notes for details.
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **Released [MemHub v1.0.5](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.5)** with Chinese, English and Spanish interfaces, one-time pairing codes for more custom Harnesses, and smoother browsing, search, startup and recovery, on **`hy-memory==2.0.8`**. See the release notes for details.
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **Released [MemHub v1.0.4](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.4)** and **[memhub-cli 1.0.5](https://www.npmjs.com/package/memhub-cli/v/1.0.5)**, with a signed macOS desktop app and verified macOS/Linux Runtimes!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Released** **Hy Memory 2.0 (`hy-memory==2.0.4`)** on [PyPI](https://pypi.org/project/hy-memory/2.0.4/) and open-sourced the **Hy-Memory** monorepo together with **[MemHub](memhub/README.md)**!
+1 -1
View File
@@ -33,7 +33,7 @@
## 最新动态
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **模型开源** 在 Hugging Face 发布 **Hy-Memory-30B-A3B** 的 **FP8**(**[Hy-Memory-30B-A3B-FP8](https://huggingface.co/tencent/Hy-Memory-30B-A3B-FP8)**)与 **GGUF**(**[Hy-Memory-30B-A3B-GGUF](https://huggingface.co/tencent/Hy-Memory-30B-A3B-GGUF)**)量化版本模型!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-11]**: 📦 **正式发布 [MemHub v1.0.6](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.6)**:界面支持 11 种语言,新增记忆模式(lite / pro / ultra,ultra 每晚自动整理),可安全切换嵌入模型,CLI 和 API 支持多用户,记忆写入自动重试、模型服务异常时暂停排队,升级更可靠;底层使用 **`hy-memory==2.0.13`**。详见发布说明。
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-11]**: 📦 **正式发布 [MemHub v1.0.6](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.6)**:界面支持 11 种语言,新增记忆模式(lite / pro / ultra,ultra 每晚自动整理),可安全切换嵌入模型,CLI 和 API 支持多用户,记忆写入自动重试、模型服务异常时暂停排队,升级更可靠;底层使用 **`hy-memory==2.0.14`**。详见发布说明。
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **正式发布 [MemHub v1.0.5](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.5)**:支持中文、英文和西班牙语界面,可通过一次性配对码连接更多自定义 Harness,并优化了记忆浏览、搜索、启动和异常恢复;底层使用 **`hy-memory==2.0.8`**。详见发布说明。
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **正式发布 [MemHub v1.0.4](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.4)** 与 **[memhub-cli 1.0.5](https://www.npmjs.com/package/memhub-cli/v/1.0.5)**,提供已签名的 macOS 桌面应用及经过验证的 macOS/Linux Runtime!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **正式发布** **Hy Memory 2.0 (`hy-memory==2.0.4`)**(已上线 [PyPI](https://pypi.org/project/hy-memory/2.0.4/)),并开源 **Hy-Memory** monorepo 与 **[MemHub](memhub/README.md)**!
+24 -5
View File
@@ -1864,6 +1864,7 @@ class HyMemoryClient:
branch: Optional[str] = None,
request_id: Optional[str] = None,
layers: Optional[List[Any]] = None,
statuses: Optional[List[Any]] = None,
embed_override: Optional[Dict[str, Any]] = None,
query_embedding: Optional[List[float]] = None,
rerank: bool = True,
@@ -1956,6 +1957,8 @@ class HyMemoryClient:
layers: 可选。pro/ultra 下只召回指定层级(字符串或
MemoryLayer),在召回阶段生效。lite 下忽略该参数并
强制只召回 L1_RAW。
statuses: 仅用于 L1_RAW 定向搜索,可显式包含 active / shadow。
非 raw 搜索或 scene="all" 传入时拒绝,避免静默忽略。
rerank: 默认 True(原行为)。False 时本次调用跳过全部重排阶段:
chat 路的本地 cross-encoder 重排(recall.local_rerank_enabled)
与记忆关系图重排(graph_rerank / episode 扩展),working/
@@ -1999,7 +2002,7 @@ class HyMemoryClient:
intention_limit=intention_limit, created_after=created_after,
reader=reader,
workspace_id=workspace_id, branch=branch,
request_id=request_id, layers=layers,
request_id=request_id, layers=layers, statuses=statuses,
embed_override=embed_override,
query_embedding=query_embedding,
rerank=rerank,
@@ -3387,12 +3390,14 @@ class HyMemoryClient:
request_id: str,
t0: float,
query_embedding: Optional[List[float]],
statuses: Optional[List[Any]],
) -> Dict[str, Any]:
"""Implement `search(..., layers=['l1_raw'])` without a second API."""
from .models.memory import MemoryLayer, MemoryStatus
embedding = query_embedding or await self._embed_service.embed(query)
fetch = min(max(limit * (10 if session_ids else 2), limit), 10_000)
status_filter = self._normalize_statuses(statuses) or [MemoryStatus.ACTIVE]
hits = await self._vector_store.search(
query_embedding=embedding,
user_ids=user_ids,
@@ -3402,7 +3407,7 @@ class HyMemoryClient:
layers=[MemoryLayer.L1_RAW],
limit=fetch,
score_threshold=min_score,
status_filter=[MemoryStatus.ACTIVE],
status_filter=status_filter,
only_latest=False,
created_after=created_after,
)
@@ -3420,7 +3425,7 @@ class HyMemoryClient:
"memory_id": row.pop("raw_id"),
**row,
"layer": MemoryLayer.L1_RAW.value,
"status": MemoryStatus.ACTIVE.value,
"status": node.status.value,
"score": round(float(hit.get("score", 0.0)), 4),
}
)
@@ -3864,6 +3869,7 @@ class HyMemoryClient:
branch: Optional[str] = None,
request_id: Optional[str] = None,
layers: Optional[List[Any]] = None,
statuses: Optional[List[Any]] = None,
embed_override: Optional[Dict[str, Any]] = None,
query_embedding: Optional[List[float]] = None,
rerank: bool = True,
@@ -3893,7 +3899,7 @@ class HyMemoryClient:
schema_min_score=schema_min_score, schema_limit=schema_limit,
intention_limit=intention_limit, created_after=created_after,
reader=reader, workspace_id=workspace_id, branch=branch,
request_id=request_id, layers=layers,
request_id=request_id, layers=layers, statuses=statuses,
query_embedding=query_embedding,
rerank=rerank,
)
@@ -3908,7 +3914,7 @@ class HyMemoryClient:
schema_min_score=schema_min_score, schema_limit=schema_limit,
intention_limit=intention_limit, created_after=created_after,
reader=reader, workspace_id=workspace_id, branch=branch,
request_id=request_id, layers=layers,
request_id=request_id, layers=layers, statuses=statuses,
query_embedding=query_embedding,
rerank=rerank,
)
@@ -3938,6 +3944,7 @@ class HyMemoryClient:
branch: Optional[str] = None,
request_id: Optional[str] = None,
layers: Optional[List[Any]] = None,
statuses: Optional[List[Any]] = None,
query_embedding: Optional[List[float]] = None,
rerank: bool = True,
) -> Dict[str, Any]:
@@ -4023,6 +4030,17 @@ class HyMemoryClient:
"Use single agent_id with multiple session_ids, or multiple agent_ids without session_ids."
)
if statuses is not None:
from .models.memory import MemoryLayer
requested_layers = self._normalize_layers(layers)
if scene_all or (
not lite_mode and requested_layers != [MemoryLayer.L1_RAW]
):
raise ValueError(
"statuses is supported only for explicit L1_RAW search"
)
if scene_all:
return await self._search_all_scenes(
query, queries=queries, search_mode=search_mode,
@@ -4358,6 +4376,7 @@ class HyMemoryClient:
request_id=request_id,
t0=t0,
query_embedding=query_embedding,
statuses=statuses,
)
read_req = ReadRequest(
+3 -3
View File
@@ -18,8 +18,8 @@ If you run into a problem, please open an Issue with the version, platform,repro
## 发行与平台合同
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.13`。MemHub 源码版本为 `1.0.6`
(build 40),GitHub 仓库为
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.14`。MemHub 源码版本为 `1.0.6`
(build 41),GitHub 仓库为
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
`memhub-cli`。当前代码架构见
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
@@ -118,7 +118,7 @@ MemHub 的 SQLite 数据默认位于 `~/.one-memory`,不在 npm 包或 Runtime
## Hy Memory
SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.13`。SDK 配置、权限边界及 search
SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.14`。SDK 配置、权限边界及 search
返回格式见 [`MEMORY.md`](MEMORY.md)。HY search 返回的 `content` 已包含 SDK 统一生成的
逻辑时间和演化上下文;调用方不应再次转换时间戳或重复拼接 `When`。
+1 -1
View File
@@ -2352,7 +2352,7 @@
{
"SPDXID": "SPDXRef-Package-hy-memory",
"name": "hy-memory",
"versionInfo": "2.0.13",
"versionInfo": "2.0.14",
"downloadLocation": "NOASSERTION",
"filesAnalyzed": false,
"licenseConcluded": "Apache-2.0",
+1 -1
View File
@@ -9,7 +9,7 @@ Per-package license texts are not reproduced here yet; the license
column is filled in as part of release review, and the SBOM records
`NOASSERTION` until then rather than guessing.
## hy-memory 2.0.13
## hy-memory 2.0.14
License: Apache-2.0
+33
View File
@@ -109,6 +109,39 @@ describe("status on a paused model", () => {
});
expect(renderStatusText(report, "MemHub")).not.toContain("模型暂停");
});
it("reports a restarting write drain and its exit code", async () => {
const report = await compose({
...processing({ llm_backoff_until: null, llm_backoff_error: null }),
write_drain: "restarting",
write_drain_error: "operational_error",
});
expect(report.sections.processing).toMatchObject({
write_drain: "restarting",
write_drain_error: "operational_error",
});
const text = renderStatusText(report, "MemHub");
expect(text).toContain(
"写入队列:异常退出(operational_error),正在自动重启",
);
expect(text).not.toContain("write_drain");
});
it("says nothing about a running drain and nulls what an older Core omits", async () => {
const running = await compose({
...processing({ llm_backoff_until: null, llm_backoff_error: null }),
write_drain: "running",
write_drain_error: null,
});
expect(renderStatusText(running, "MemHub")).not.toContain("写入队列");
const older = await compose(
processing({ llm_backoff_until: null, llm_backoff_error: null }),
);
expect(older.sections.processing).toMatchObject({
write_drain: null,
write_drain_error: null,
});
});
});
describe("status and doctor on an unfinished upgrade migration", () => {
+37
View File
@@ -323,6 +323,8 @@ export function renderStatusText(
if (embeddingSwitch !== null) lines.push(`向量模型:${embeddingSwitch}`);
const backoff = modelBackoffLine(report.sections.processing);
if (backoff !== null) lines.push(backoff);
const drain = writeDrainLine(report.sections.processing);
if (drain !== null) lines.push(drain);
if (report.summary.state === "partial") {
const notes = [
report.summary.degraded.length > 0
@@ -353,6 +355,20 @@ function modelBackoffLine(section: Section | undefined): string | null {
}`;
}
/** The durable write drain, when it is not simply running: it exited on an
* error and is coming back (`restarting`), or it is not running at all. */
function writeDrainLine(section: Section | undefined): string | null {
if (section?.state !== "ok") return null;
const state = section.write_drain;
if (state !== "restarting" && state !== "stopped") return null;
const error = section.write_drain_error;
const reason =
typeof error === "string" && error.length > 0 ? `(${error})` : "";
return state === "restarting"
? `写入队列:异常退出${reason},正在自动重启`
: `写入队列:已停止${reason}`;
}
export function pipelineLine(pipeline: PipelineSummary): string {
const parts: string[] = [pipeline.mode];
if (pipeline.forced_by_env)
@@ -410,6 +426,8 @@ const SECTION_LINE_OMITTED = new Set([
"embedding_switch",
"llm_backoff_until",
"llm_backoff_error",
"write_drain",
"write_drain_error",
"content_plane",
"visible_count",
"effective_mode",
@@ -549,6 +567,7 @@ function describeProcessing(
})),
suspended: typeof summary.suspended === "number" ? summary.suspended : 0,
...llmBackoffFields(summary),
...writeDrainFields(summary),
...embeddingSwitchField(summary),
};
}
@@ -569,6 +588,24 @@ function llmBackoffFields(summary: MemoryProcessingSummary): {
};
}
/** Durable write drain liveness. Null when Core does not report it (older
* Core, or recovery has not started the drain yet). */
function writeDrainFields(summary: MemoryProcessingSummary): {
write_drain: "running" | "restarting" | "stopped" | null;
write_drain_error: string | null;
} {
const state = summary.write_drain;
const error = summary.write_drain_error;
return {
write_drain:
state === "running" || state === "restarting" || state === "stopped"
? state
: null,
write_drain_error:
typeof error === "string" && /^[a-z_]{1,64}$/.test(error) ? error : null,
};
}
/** The running switch or merge, projected; absent when none runs. */
function embeddingSwitchField(
summary: MemoryProcessingSummary,
+1 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"fastapi>=0.141.1",
"filelock>=3.32.4",
# Public SDK version validated against MemHub's permission and recall contracts.
"hy-memory==2.0.13",
"hy-memory==2.0.14",
# 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",
+338 -96
View File
@@ -49,6 +49,7 @@ from one_memory.auth import local_api_guard, renderer_origins
from one_memory.background_work import (
in_sdk_read,
in_the_background,
in_the_write_queue,
shutdown_bulk_pool,
warm_bulk_pool,
)
@@ -164,6 +165,7 @@ from one_memory.imports.grants import (
)
from one_memory.imports.host_packages import CapabilityState, HostPackageCapabilities
from one_memory.imports.jobs import (
PRUNE_BATCH_ROWS,
ImportIssueRecord,
ImportJob,
ImportJobStore,
@@ -270,6 +272,7 @@ from one_memory.onboarding import (
OnboardingState,
OnboardingStore,
)
from one_memory.raw_turns import RawDisplayTurn, raw_display_turns, raw_prefix_added
from one_memory.recall import (
GovernedRecallTarget,
RecallReceipt,
@@ -604,24 +607,30 @@ class SdkRawItem(HySdkRawRecord):
role: str | None = None
content_hash: str
created: datetime | None = None
turn_id: str | None = Field(default=None, min_length=1, max_length=96)
turn_index: int | None = Field(default=None, ge=0)
turn_count: int | None = Field(default=None, ge=1)
@classmethod
def of(cls, record: HySdkRawRecord) -> "SdkRawItem":
messages = tuple(
SdkRawMessage(role=role, content=text)
for role, text in raw_turn_messages(record.content)
)
def of(cls, record: HySdkRawRecord, turn: RawDisplayTurn | None = None) -> "SdkRawItem":
content = turn.content if turn is not None else record.content
raw_messages = turn.messages if turn is not None else raw_turn_messages(record.content)
messages = tuple(SdkRawMessage(role=role, content=text) for role, text in raw_messages)
roles = {message.role for message in messages}
return cls(
**record.model_dump(),
**record.model_dump(exclude={"content"}),
content=content,
messages=messages,
role=next(iter(roles)) if len(roles) == 1 and "" not in roles else None,
content_hash="sha256:" + hashlib.sha256(record.content.encode("utf-8")).hexdigest(),
content_hash="sha256:" + hashlib.sha256(content.encode("utf-8")).hexdigest(),
created=(
datetime.fromtimestamp(record.gmt_created, tz=UTC)
if record.gmt_created is not None
else None
),
turn_id=turn.turn_id if turn is not None else None,
turn_index=turn.turn_index if turn is not None else None,
turn_count=turn.turn_count if turn is not None else None,
)
@@ -634,9 +643,9 @@ class SdkRawPage(BaseModel):
total: int = Field(ge=0)
offset: int = Field(ge=0)
limit: int = Field(ge=1, le=500)
#: Set when a time filter was applied to this page in Core (the SDK list
#: has none): `items` is the page's rows inside the range, `total` stays
#: the SDK's count before that filter.
unit: Literal["raw", "turn"] = "raw"
#: Set when a time filter was applied in Core. For storage `raw`, `total`
#: remains the SDK count; for product `turn`, it is the filtered turn count.
time_filtered: bool = False
@@ -736,6 +745,12 @@ class MemoryProcessingSummary(BaseModel):
#: The last provider failure behind the pause, as a stable code
#: (`model_auth_failed`, `model_not_found`, `model_unreachable`, ...).
llm_backoff_error: str | None = None
#: The durable write drain, the queue's only consumer: `running`,
#: `restarting` (it exited on an error and comes back after a pause) or
#: `stopped`; None before startup recovery has started it.
write_drain: Literal["running", "restarting", "stopped"] | None = None
#: Why the drain last exited unexpectedly, as a stable code; None if never.
write_drain_error: str | None = None
class MemoryModeState(BaseModel):
@@ -1495,7 +1510,7 @@ def create_app(
# under a new SDK request id; the re-run first asks the SDK
# whether the old one landed (`execute_job`). Nothing stays
# `unknown` waiting for a person.
await in_the_background(
await in_the_write_queue(
memory_write_queue.recover_unknown,
in_flight=sdk_write_in_flight,
)
@@ -1624,6 +1639,7 @@ def create_app(
# Started by `recover_after_ready`: a running drain claims jobs and
# holds the single writer, which the rest of startup still needs.
app.state.memory_write_drain_task = None
app.state.import_job_pruning = None
# D52: every entry that already holds committed Evidence goes
# through the one durable queue. The intake turns one record into
# one complete-turn request; the drain stays the only consumer, so
@@ -1866,12 +1882,21 @@ def create_app(
app.state.embedding_switch.start_retention()
async def start_write_drain() -> None:
# Supervised: an unexpected exit is reported and the drain
# restarts, instead of stranding every queued write.
app.state.memory_write_drain_task = watched_task(
asyncio.get_running_loop(),
lambda: memory_write_drain.run_forever(),
lambda: memory_write_drain.run_supervised(),
name="one-memory-durable-write-drain",
)
async def start_import_job_pruning() -> None:
app.state.import_job_pruning = watched_task(
asyncio.get_running_loop(),
lambda: _prune_finished_import_jobs(app.state.import_job_store),
name="one-memory-import-job-pruning",
)
async def resume_graph_backfill() -> None:
saved = app.state.evolve_state_store.graph_build()
if saved.state != "running":
@@ -1942,7 +1967,7 @@ def create_app(
)
async def resume_imports() -> None:
app.state.import_supervisor.resume_interrupted()
await app.state.import_supervisor.resume_interrupted_off_loop()
async def start_live_sync() -> None:
if runtime_settings.live_sync_enabled:
@@ -1982,6 +2007,7 @@ def create_app(
await step("evolve_schedule", start_evolve_schedule)
else:
await step("pending_dispatch_replay", replay_pending_dispatch)
await step("import_job_pruning", start_import_job_pruning)
await step("import_resume", resume_imports)
await step("live_sync", start_live_sync)
await step("source_reconcile", _reconcile_source_sync_from_config)
@@ -2071,6 +2097,10 @@ def create_app(
# Recovery never reached the metadata repair step.
release_stores(runtime_settings.data_dir)
app.state.sdk_stores_held = False
if app.state.import_job_pruning is not None:
app.state.import_job_pruning.cancel()
with suppress(asyncio.CancelledError):
await app.state.import_job_pruning
await app.state.import_supervisor.shutdown()
app.state.memory_write_drain.stop()
if app.state.memory_write_drain_task is not None:
@@ -2809,17 +2839,16 @@ def create_app(
await _refuse_overlapping_root(source_id, registration.candidate.path, DEFAULT_USER_ID)
root_changed = prior is not None and prior.root_id != request.expected_root_id
if root_changed:
source_jobs = [job for job in _job_store(app).list_jobs() if job.source_id == source_id]
source_jobs = await asyncio.to_thread(
partial(
_job_store(app).find_jobs,
source_id=source_id,
statuses=_UNFINISHED_IMPORT_STATUSES,
)
)
if any(job.status is ImportStatus.RUNNING for job in source_jobs):
raise HTTPException(status_code=409, detail="source_import_busy")
for job in source_jobs:
if job.status not in {
ImportStatus.PENDING,
ImportStatus.PAUSED,
ImportStatus.INTERRUPTED,
ImportStatus.FAILED,
}:
continue
await _supervisor(app).cancel(job.job_id)
try:
receipt = await asyncio.to_thread(
@@ -2998,21 +3027,18 @@ def create_app(
user's own cursors and history job.
"""
await _live_sync(app).initialize_source(source_id, baseline_missing_files=True)
active = {
ImportStatus.PENDING,
ImportStatus.PAUSED,
ImportStatus.INTERRUPTED,
ImportStatus.RUNNING,
ImportStatus.FAILED,
}
if any(
lane_key(job.user_id, job.source_id) == source_id
and job.operation == "history"
and job.status in active
for job in _job_store(app).list_jobs()
):
return
user_id, bare_source_id = split_lane(source_id)
active_history = await asyncio.to_thread(
partial(
_job_store(app).latest_job,
user_id=user_id,
source_id=bare_source_id,
operation="history",
statuses=_UNFINISHED_IMPORT_STATUSES,
)
)
if active_history is not None:
return
await asyncio.to_thread(
_job_store(app).create,
user_id=user_id,
@@ -3022,18 +3048,25 @@ def create_app(
async def _activate_source_resume(source_id: str) -> None:
"""Run work prepared for a source after config.json has committed."""
user_id, bare_source_id = split_lane(source_id)
try:
for job in _job_store(app).list_jobs():
if lane_key(job.user_id, job.source_id) != source_id:
continue
for job in await asyncio.to_thread(
partial(
_job_store(app).find_jobs,
user_id=user_id,
source_id=bare_source_id,
statuses=(
ImportStatus.PENDING,
ImportStatus.PAUSED,
ImportStatus.INTERRUPTED,
ImportStatus.FAILED,
),
)
):
if job.status is ImportStatus.PENDING:
_supervisor(app).enqueue(job.job_id)
elif job.status in {
ImportStatus.PAUSED,
ImportStatus.INTERRUPTED,
ImportStatus.FAILED,
}:
_supervisor(app).resume(job.job_id)
await _supervisor(app).enqueue_off_loop(job.job_id, job=job)
else:
await _supervisor(app).resume_off_loop(job.job_id)
except SourceAccessError as error:
raise HTTPException(status_code=409, detail=error.code) from None
if app.state.settings.automatic_memory_processing:
@@ -3060,22 +3093,19 @@ def create_app(
_require_registration(app, source_id)
if user_id != DEFAULT_USER_ID:
return await _revoke_other_user_grant(source_id, user_id)
receipt = _grant_store(app).receipt(
user_id="local",
source_id=source_id,
receipt = await asyncio.to_thread(
partial(_grant_store(app).receipt, user_id="local", source_id=source_id)
)
if receipt is None:
raise HTTPException(status_code=404, detail="grant_not_found")
active_statuses = {
ImportStatus.PENDING,
ImportStatus.RUNNING,
ImportStatus.PAUSED,
ImportStatus.INTERRUPTED,
ImportStatus.FAILED,
}
for job in _job_store(app).list_jobs():
if job.source_id == source_id and job.status in active_statuses:
await _supervisor(app).cancel(job.job_id)
for job in await asyncio.to_thread(
partial(
_job_store(app).find_jobs,
source_id=source_id,
statuses=_UNFINISHED_IMPORT_STATUSES,
)
):
await _supervisor(app).cancel(job.job_id)
# Revocation is the user's node for "stop using this source" (D73):
# park unfinished extraction instead of leaving it to fail later, so
# re-authorizing resumes the same work.
@@ -3087,9 +3117,8 @@ def create_app(
await _live_sync(app).stop_source(source_id)
_supervisor(app).unregister_adapter(source_id)
_detection(app).pop(source_id, None)
receipt = _grant_store(app).revoke(
user_id="local",
source_id=source_id,
receipt = await asyncio.to_thread(
partial(_grant_store(app).revoke, user_id="local", source_id=source_id)
)
await asyncio.to_thread(_config_store(app).set_sync, source_id, False)
await _refresh_live_watch(app)
@@ -3105,16 +3134,15 @@ def create_app(
)
if receipt is None:
raise HTTPException(status_code=404, detail="grant_not_found")
active_statuses = {
ImportStatus.PENDING,
ImportStatus.RUNNING,
ImportStatus.PAUSED,
ImportStatus.INTERRUPTED,
ImportStatus.FAILED,
}
for job in _job_store(app).list_jobs():
if lane_key(job.user_id, job.source_id) == lane and job.status in active_statuses:
await _supervisor(app).cancel(job.job_id)
for job in await asyncio.to_thread(
partial(
_job_store(app).find_jobs,
user_id=user_id,
source_id=source_id,
statuses=_UNFINISHED_IMPORT_STATUSES,
)
):
await _supervisor(app).cancel(job.job_id)
await _live_sync(app).stop_source(lane)
_supervisor(app).unregister_adapter(lane)
receipt = await asyncio.to_thread(
@@ -3179,7 +3207,7 @@ def create_app(
)
except SourceAccessError as error:
raise HTTPException(status_code=409, detail=error.code) from None
_supervisor(app).enqueue(job.job_id)
await _supervisor(app).enqueue_off_loop(job.job_id, job=job)
return job
@app.post(
@@ -3474,8 +3502,10 @@ def create_app(
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
) -> list[ImportJob]:
# One user's jobs: the App reads `local`'s and must not show another
# user's Hermes import as its own.
return [job for job in _job_store(app).list_jobs() if job.user_id == user_id]
# user's Hermes import as its own. Every one of them, oldest first, as
# before; finished live syncs past the newest twenty per source are
# pruned (`prune_finished_sync_jobs`), so this list stays short.
return _job_store(app).find_jobs(user_id=user_id)
@app.get(
"/api/v1/live-sync",
@@ -3572,7 +3602,7 @@ def create_app(
dependencies=protected,
)
async def pause_import_job(job_id: str) -> ImportJob:
_require_job(app, job_id)
await asyncio.to_thread(_require_job, app, job_id)
return await _translate_transition(_supervisor(app).pause(job_id))
@app.post(
@@ -3581,11 +3611,11 @@ def create_app(
dependencies=protected,
)
async def resume_import_job(job_id: str) -> ImportJob:
job = _require_job(app, job_id)
job = await asyncio.to_thread(_require_job, app, job_id)
if job.status in {ImportStatus.COMPLETED, ImportStatus.CANCELLED}:
raise HTTPException(status_code=409, detail="job_not_resumable")
try:
return _supervisor(app).resume(job_id)
return await _supervisor(app).resume_off_loop(job_id)
except InvalidJobTransitionError:
raise HTTPException(409, "invalid_job_transition") from None
@@ -3595,7 +3625,7 @@ def create_app(
dependencies=protected,
)
async def cancel_import_job(job_id: str) -> ImportJob:
_require_job(app, job_id)
await asyncio.to_thread(_require_job, app, job_id)
return await _translate_transition(_supervisor(app).cancel(job_id))
@app.get(
@@ -4441,6 +4471,16 @@ def create_app(
raise _sdk_http_error(error, "hy_memory_delete_failed") from None
return Response(status_code=204)
raw_turn_cache: dict[
tuple[object, ...],
tuple[
int,
tuple[tuple[str, str | None], ...],
tuple[tuple[HySdkRawRecord, RawDisplayTurn], ...],
],
] = {}
raw_turn_cache_locks: dict[tuple[object, ...], asyncio.Lock] = {}
@app.get(
"/api/v2/raw",
response_model=SdkRawPage,
@@ -4450,6 +4490,7 @@ def create_app(
source: list[str] | None = Query(default=None), # noqa: B008
offset: int = Query(default=0, ge=0),
limit: int = Query(default=100, ge=1, le=500),
unit: Literal["raw", "turn"] = Query(default="raw"),
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
exclude_sources: list[str] | None = Query(default=None), # noqa: B008
session_id: list[str] | None = Query(default=None), # noqa: B008
@@ -4463,7 +4504,10 @@ def create_app(
The SDK lists raw only when asked for `layers=["l1_raw"]`, and only
active rows unless told otherwise. Pro shadows a raw once extraction
succeeds, so a raw page reads active and shadow (`statuses` narrows
it). `source` and `session_id` (a conversation) may repeat or be comma
it). `unit=turn` projects storage batches into stable product turns;
the first read builds an append-only in-memory index and later polls
only probe the latest SDK page unless it changed. `source` and
`session_id` (a conversation) may repeat or be comma
separated. `after`/`before` (unix seconds, against the conversation
time, else the stored time) have no SDK list filter: they cut this
page in Core and mark it `time_filtered`.
@@ -4477,24 +4521,158 @@ def create_app(
sessions = split_csv(session_id)
try:
client = await _sdk_client_handle(app).client()
value = await asyncio.to_thread(
client.list_memories,
user_id=user_id,
query = {
"user_id": user_id,
**raw_list_filters(chosen_statuses),
include_sources=sources or None,
exclude_sources=excluded or None,
session_ids=sessions or None,
offset=offset,
limit=limit,
order=order,
)
page = HySdkRawPage.from_memory_list(value, limit=limit, offset=offset)
"include_sources": sources or None,
"exclude_sources": excluded or None,
"session_ids": sessions or None,
"order": order if unit == "raw" else "desc",
}
if unit == "raw":
value = await asyncio.to_thread(
client.list_memories,
**query,
offset=offset,
limit=limit,
)
page = HySdkRawPage.from_memory_list(value, limit=limit, offset=offset)
records = page.raw
else:
cache_key = (
user_id,
tuple(sorted(chosen_statuses)),
tuple(sorted(sources)),
tuple(sorted(excluded)),
tuple(sorted(sessions)),
)
if len(raw_turn_cache_locks) > 64:
for stale_key, stale_lock in tuple(raw_turn_cache_locks.items()):
if stale_key not in raw_turn_cache and not stale_lock.locked():
raw_turn_cache_locks.pop(stale_key, None)
lock = raw_turn_cache_locks.setdefault(cache_key, asyncio.Lock())
async with lock:
for _attempt in range(3):
value = await asyncio.to_thread(
client.list_memories,
**query,
offset=0,
limit=500,
)
first_page = HySdkRawPage.from_memory_list(value, limit=500, offset=0)
signature = tuple(
(record.raw_id, record.status) for record in first_page.raw
)
cached = raw_turn_cache.get(cache_key)
if (
cached is not None
and cached[0] == first_page.total
and cached[1] == signature
):
projected_turns = cached[2]
break
added = (
None
if cached is None
else raw_prefix_added(cached[1], signature, cached[0], first_page.total)
)
if cached is not None and added is not None:
# A live session only puts new raws on top: project
# those and keep the rest, instead of re-listing the
# whole raw layer on every poll.
projected_turns = (
tuple(
(record, turn)
for record in first_page.raw[:added]
for turn in reversed(raw_display_turns(record))
)
+ cached[2]
)
raw_turn_cache[cache_key] = (
first_page.total,
signature,
projected_turns,
)
break
records_list = list(first_page.raw)
raw_offset = len(first_page.raw)
raw_total = first_page.total
while first_page.raw and raw_offset < raw_total:
value = await asyncio.to_thread(
client.list_memories,
**query,
offset=raw_offset,
limit=500,
)
raw_page = HySdkRawPage.from_memory_list(
value, limit=500, offset=raw_offset
)
records_list.extend(raw_page.raw)
raw_offset += len(raw_page.raw)
if not raw_page.raw:
break
verify_value = await asyncio.to_thread(
client.list_memories,
**query,
offset=0,
limit=500,
)
verify = HySdkRawPage.from_memory_list(verify_value, limit=500, offset=0)
verify_signature = tuple(
(record.raw_id, record.status) for record in verify.raw
)
if verify.total != first_page.total or verify_signature != signature:
continue
projected_turns = tuple(
(record, turn)
for record in records_list
for turn in reversed(raw_display_turns(record))
)
raw_turn_cache[cache_key] = (
first_page.total,
signature,
projected_turns,
)
while len(raw_turn_cache) > 32:
oldest = next(iter(raw_turn_cache))
raw_turn_cache.pop(oldest)
old_lock = raw_turn_cache_locks.get(oldest)
if old_lock is not None and not old_lock.locked():
raw_turn_cache_locks.pop(oldest, None)
break
else:
raise RuntimeError("hy_memory_raw_snapshot_busy")
if cache_key not in raw_turn_cache and not lock.locked():
raw_turn_cache_locks.pop(cache_key, None)
except HTTPException:
raise
except Exception as error:
raise _sdk_http_error(error, "hy_memory_raw_list_failed") from None
records = page.raw
time_filtered = after is not None or before is not None
if unit == "turn":
turns = projected_turns
if time_filtered:
turns = tuple(
(record, turn)
for record, turn in turns
if (at := record.memory_at or record.gmt_created) is not None
and (after is None or at >= after)
and (before is None or at < before)
)
if order == "asc":
turns = tuple(reversed(turns))
selected = turns[offset : offset + limit]
return SdkRawPage(
user_id=user_id,
items=tuple(SdkRawItem.of(record, turn) for record, turn in selected),
total=len(turns),
offset=offset,
limit=limit,
unit="turn",
time_filtered=time_filtered,
)
if time_filtered:
records = tuple(
record
@@ -4509,6 +4687,7 @@ def create_app(
total=page.total,
offset=offset,
limit=limit,
unit="raw",
time_filtered=time_filtered,
)
@@ -4705,6 +4884,7 @@ def create_app(
running_rows = counts.get(MemoryTaskStatus.RUNNING, 0)
backoff = cast(ModelBackoff | None, getattr(app.state, "model_backoff", None))
backoff_state = backoff.state() if backoff is not None else None
drain = getattr(app.state, "memory_write_drain", None)
return MemoryProcessingSummary(
pending=(
sdk_counts.get("pending", 0)
@@ -4752,6 +4932,8 @@ def create_app(
if backoff_state is not None and backoff_state.until is not None
else None
),
write_drain=getattr(drain, "liveness", None),
write_drain_error=getattr(drain, "last_error_code", None),
)
@app.get(
@@ -5549,6 +5731,16 @@ def _diagnostic_request_details(request: Request, event: str | None) -> dict[str
return details
#: Every state a job can still leave: the ones a revoke or re-root cancels.
_UNFINISHED_IMPORT_STATUSES = (
ImportStatus.PENDING,
ImportStatus.RUNNING,
ImportStatus.PAUSED,
ImportStatus.INTERRUPTED,
ImportStatus.FAILED,
)
def _require_job(app: FastAPI, job_id: str) -> ImportJob:
try:
return _job_store(app).require(job_id)
@@ -5823,6 +6015,54 @@ def _backfill_state(
return "running"
#: How often finished live syncs are pruned while the Core runs, and how many
#: bounded batches one such pass may take (`prune_finished_sync_jobs`).
IMPORT_JOB_PRUNE_SECONDS = 600.0
IMPORT_JOB_PRUNE_PASS_BATCHES = 10
async def _prune_finished_import_jobs(
store: ImportJobStore,
*,
interval_seconds: float = IMPORT_JOB_PRUNE_SECONDS,
pass_batches: int = IMPORT_JOB_PRUNE_PASS_BATCHES,
) -> None:
"""Keep `import_jobs` bounded: at startup down to the keep count, then on a timer.
Every batch is its own short write transaction, made off the loop, with a
pause between batches so the writer's line keeps moving. The first pass
runs until nothing is left, which on a home that grew for weeks is the
one long pass; after it each pass is at most `pass_batches` batches.
A busy database skips the rest of a pass rather than failing it.
"""
first = True
while True:
removed_total = 0
batches = 0
while first or batches < pass_batches:
try:
removed = await in_the_background(store.prune_finished_sync_jobs)
except sqlite3.OperationalError as error:
if "locked" not in str(error).lower() and "busy" not in str(error).lower():
raise
logger.info("import_jobs_prune_deferred error_type=%s", type(error).__name__)
break
batches += 1
removed_total += removed
if removed < PRUNE_BATCH_ROWS:
break
await asyncio.sleep(0.05)
if removed_total:
logger.info(
"import_jobs_pruned removed=%d batches=%d startup=%s",
removed_total,
batches,
first,
)
first = False
await asyncio.sleep(interval_seconds)
async def _keep_loop_heartbeat(watchdog: LoopWatchdog) -> None:
"""The loop saying it is still able to run something."""
# The watchdog already logs each stall with a stack, but a stack is prose:
@@ -6222,7 +6462,7 @@ async def _search_sdk_raw(
`filters` are a caller's pass-through options: its floor and row filters
apply, and its `include_sources` only narrow `sources`, never widen them.
"""
from one_memory.context_routes import raw_lane_overrides
from one_memory.context_routes import raw_lane_overrides, raw_lane_statuses
include = sorted(sources) if sources else None
if filters is not None and filters.include_sources is not None:
@@ -6249,6 +6489,8 @@ async def _search_sdk_raw(
"scene": "chat",
"reader": HY_MEMORY_READER,
"layers": ["l1_raw"],
# Lite recall is plain RAG over every raw, shadow included.
**raw_lane_statuses(lite=app.state.memory_mode() == "lite"),
**(raw_lane_overrides(filters) if filters is not None else {}),
},
)
@@ -6863,12 +7105,12 @@ def _history_import_gate_released(app: FastAPI) -> bool:
app.state.history_import_gate_released = True
return True
selected = set(state.selected_source_ids)
# Asked inside the drain's claim, per candidate: history jobs of `local`
# only, in SQL, never the whole table.
matching = [
job
for job in jobs.list_jobs()
if job.operation == "history"
and job.user_id == DEFAULT_USER_ID
and job.source_id in selected
for job in jobs.find_jobs(user_id=DEFAULT_USER_ID, operation="history")
if job.source_id in selected
and (state.started_at is None or job.created_at >= state.started_at)
]
if {job.source_id for job in matching} != selected:
@@ -20,6 +20,15 @@ SOURCE_READER_WORKERS = 4
SDK_WRITE_WORKERS = 12
# Recall's SDK reads: one search and the `get`s behind it (`_SDK_GET_CONCURRENCY`).
SDK_READ_WORKERS = 8
# The durable write queue's own bookkeeping: claim, settle, the per-job reads
# around an SDK write, and the drain's maintenance passes. Each is one short
# statement on the single writer or a reader; the drain runs at most
# `max_concurrent_sources` jobs, and they wait for the writer, not for CPU.
DURABLE_QUEUE_WORKERS = 4
# Lease renewal alone. Renewal is the drain's ownership fence: a lease that
# lapses while its SDK write is still running hands the job to recovery, so it
# must never wait for a thread behind any other work, its own queue's included.
LEASE_RENEWAL_WORKERS = 2
_pool: ThreadPoolExecutor | None = None
_snapshot_pool: ThreadPoolExecutor | None = None
@@ -28,6 +37,8 @@ _raw_read_pool: ThreadPoolExecutor | None = None
_memory_read_pool: ThreadPoolExecutor | None = None
_source_reader_pool: ThreadPoolExecutor | None = None
_sdk_write_pool: ThreadPoolExecutor | None = None
_durable_queue_pool: ThreadPoolExecutor | None = None
_lease_renewal_pool: ThreadPoolExecutor | None = None
_warmed_readers: weakref.WeakSet[ThreadPoolExecutor] = weakref.WeakSet()
_reader_warm_lock = threading.Lock()
_interactive_reads = 0
@@ -109,6 +120,35 @@ def sdk_write_pool() -> ThreadPoolExecutor:
return _sdk_write_pool
def durable_queue_pool() -> ThreadPoolExecutor:
"""The durable write queue's database work, apart from bulk work.
The queue used to hand its claims, settles and maintenance to the bulk
pool, whose three threads also carry import heartbeats and job listings.
Under an import peak all three sat in those for tens of seconds, and the
queue's next statement waited for a thread before it could even join the
writer's line, and leases lapsed while their SDK writes were still running.
"""
global _durable_queue_pool
if _durable_queue_pool is None:
_durable_queue_pool = ThreadPoolExecutor(
max_workers=DURABLE_QUEUE_WORKERS,
thread_name_prefix="one-memory-write-queue",
)
return _durable_queue_pool
def lease_renewal_pool() -> ThreadPoolExecutor:
"""Threads that only renew durable write leases (`DurableWriteDrain.renew`)."""
global _lease_renewal_pool
if _lease_renewal_pool is None:
_lease_renewal_pool = ThreadPoolExecutor(
max_workers=LEASE_RENEWAL_WORKERS,
thread_name_prefix="one-memory-lease-renewal",
)
return _lease_renewal_pool
def source_reader_pool() -> ThreadPoolExecutor:
"""Bounded process-wide pool for source iteration and parsing."""
global _source_reader_pool
@@ -128,9 +168,21 @@ def warm_bulk_pool() -> None:
Starting a thread is nothing on an idle machine and 2.5 seconds on a busy
one, which is precisely when bulk work is being handed over. So it is
done here instead, at startup, before there is anything to be late for.
The durable write queue's pools are warmed here too, for the same reason:
their first renewal must not pay for starting a thread.
"""
pool = bulk_pool()
started = tuple(threading.Event() for _ in range(BULK_WORKERS))
_start_every_worker(bulk_pool(), BULK_WORKERS, "bulk_pool_warmup_timeout")
_start_every_worker(
durable_queue_pool(), DURABLE_QUEUE_WORKERS, "durable_queue_pool_warmup_timeout"
)
_start_every_worker(
lease_renewal_pool(), LEASE_RENEWAL_WORKERS, "lease_renewal_pool_warmup_timeout"
)
def _start_every_worker(pool: ThreadPoolExecutor, workers: int, timeout_code: str) -> None:
started = tuple(threading.Event() for _ in range(workers))
release = threading.Event()
def occupy(marker: threading.Event) -> None:
@@ -146,7 +198,7 @@ def warm_bulk_pool() -> None:
try:
for marker in started:
if not marker.wait(10):
raise RuntimeError("bulk_pool_warmup_timeout")
raise RuntimeError(timeout_code)
finally:
release.set()
for future in futures:
@@ -228,6 +280,38 @@ async def in_sdk_write[T](
)
async def in_the_write_queue[T](
work: Callable[..., T],
/,
*args: object,
**kwargs: object,
) -> T:
"""One durable write queue statement, in the queue's pool (`durable_queue_pool`).
It still goes through the one writer and its line, like every write; what
it no longer does is wait for a bulk thread first.
"""
return await asyncio.get_running_loop().run_in_executor(
durable_queue_pool(),
copy_context().run,
partial(work, *args, **kwargs),
)
async def in_lease_renewal[T](
work: Callable[..., T],
/,
*args: object,
**kwargs: object,
) -> T:
"""One lease renewal, in the threads kept for nothing else (`lease_renewal_pool`)."""
return await asyncio.get_running_loop().run_in_executor(
lease_renewal_pool(),
copy_context().run,
partial(work, *args, **kwargs),
)
def _retrieve[T](future: "asyncio.Future[T]") -> None:
"""Mark a shielded read's outcome as seen.
@@ -314,7 +398,13 @@ def _warm_reader_pool(reader: ThreadPoolExecutor) -> None:
def shutdown_bulk_pool() -> None:
global _pool, _snapshot_pool, _interactive_pool, _raw_read_pool, _memory_read_pool
global _source_reader_pool, _sdk_write_pool
global _source_reader_pool, _sdk_write_pool, _durable_queue_pool, _lease_renewal_pool
if _durable_queue_pool is not None:
_durable_queue_pool.shutdown(wait=False, cancel_futures=True)
_durable_queue_pool = None
if _lease_renewal_pool is not None:
_lease_renewal_pool.shutdown(wait=False, cancel_futures=True)
_lease_renewal_pool = None
if _sdk_write_pool is not None:
_sdk_write_pool.shutdown(wait=False, cancel_futures=True)
_sdk_write_pool = None
@@ -66,6 +66,8 @@ class SdkContextSearchRequest(ContextSearchRequest, SdkSearchOptions):
limit: int = Field(default=10, ge=1, le=20, strict=True)
schema_limit: int = Field(default=3, ge=0, le=20, strict=True)
intention_limit: int = Field(default=1, ge=0, le=20, strict=True)
#: Product callers can request one item per turn instead of SDK raw batches.
raw_unit: Literal["raw", "turn"] = "raw"
#: Whose memories to search. The App never sends it and reads `local`.
user_id: UserId = DEFAULT_USER_ID
@@ -123,6 +125,9 @@ class SdkRawContextItem(BaseModel):
memory_at: datetime | None = None
score: float = Field(ge=0, le=1)
truncated: bool = False
turn_id: str | None = Field(default=None, min_length=1, max_length=96)
turn_index: int | None = Field(default=None, ge=0)
turn_count: int | None = Field(default=None, ge=1)
class SdkMemoryContextItem(BaseModel):
@@ -30,6 +30,7 @@ from one_memory.hy_sdk_types import (
parse_sdk_search,
)
from one_memory.imports.storage import EvidenceStore
from one_memory.raw_turns import raw_display_turns, turns_for_hit
from one_memory.sdk_search_options import SdkSearchFilters, SearchLane
from one_memory.semantic_deadline import SemanticMemoryHit
from one_memory.user_scope import owns
@@ -70,6 +71,18 @@ def raw_lane_overrides(value: SdkSearchFilters) -> dict[str, object]:
return found
def raw_lane_statuses(*, lite: bool) -> dict[str, list[str]]:
"""Which raw statuses the raw lane searches.
Lite extracts nothing, so its search is plain RAG over every raw,
including the shadow raws pro already processed before the switch to
lite. Pro and ultra keep the SDK default (active only): a shadow raw is
already represented by the memories extracted from it, and returning it
again would repeat that content in the caller's context budget.
"""
return {"statuses": ["active", "shadow"]} if lite else {}
def hit_namespace(hit: SemanticMemoryHit) -> dict[str, str]:
"""The `get` namespace of a hit: nothing for chat, kind and variant otherwise."""
if hit.kind in {"experience", "fact"} and hit.variant is not None:
@@ -120,6 +133,9 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
sources = raw_lane_sources(authority.raw_sources, value.include_sources)
if sources is None:
return ()
# Plain RAG: every vector hit over the floor is an answer, ranked
# by its score. Each hit yields at least one item, so the SDK
# needs to return no more hits than the caller's limit.
raw = await asyncio.to_thread(
client.search,
value.query,
@@ -135,27 +151,60 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
"scene": "chat",
"reader": HY_MEMORY_READER,
"layers": ["l1_raw"],
**raw_lane_statuses(lite=lite),
**raw_lane_overrides(value),
},
)
hits = tuple(
hit for hit in parse_sdk_raw_search(raw) if value.passes_floor("raw", hit.score)
)
return tuple(
SdkRawContextItem(
raw_id=hit.raw_id,
content=hit.content,
source=hit.source,
session_id=hit.session_id,
memory_at=(
datetime.fromtimestamp(hit.memory_at, tz=UTC)
if hit.memory_at is not None
else None
),
score=hit.score,
# One item per hit first, so a hit is never crowded out by another
# hit's sibling turns; the siblings fill whatever room is left.
primary: list[SdkRawContextItem] = []
siblings: list[SdkRawContextItem] = []
for hit in hits:
memory_at = (
datetime.fromtimestamp(hit.memory_at, tz=UTC)
if hit.memory_at is not None
else None
)
for hit in hits
)
turns = raw_display_turns(hit) if value.raw_unit == "turn" else ()
if not turns:
primary.append(
SdkRawContextItem(
raw_id=hit.raw_id,
content=hit.content,
source=hit.source,
session_id=hit.session_id,
memory_at=memory_at,
score=hit.score,
)
)
continue
# The embedding is the batch's: the hit is shown by the turns
# that contain the query, or else by its closest turn, each
# with the hit's score.
shown = [
SdkRawContextItem(
raw_id=hit.raw_id,
turn_id=turn.turn_id,
turn_index=turn.turn_index,
turn_count=turn.turn_count,
content=turn.content,
source=hit.source,
session_id=hit.session_id,
memory_at=memory_at,
score=hit.score,
)
for turn in turns_for_hit(turns, value.query)
]
primary.append(shown[0])
siblings.extend(shown[1:])
chosen = primary[: value.limit]
chosen.extend(siblings[: value.limit - len(chosen)])
# Stable: a hit's turns stay together, in hit-score order.
chosen.sort(key=lambda item: item.score, reverse=True)
return tuple(chosen)
async def memory_lane() -> tuple[SdkMemoryContextItem, ...]:
# lite extracts nothing: its search answers raw turns, which the
@@ -242,6 +242,75 @@ class _InTurn:
self._line.remove(place)
class _Checkpointer:
"""The one thread that runs cadence checkpoints, for every main database.
A database asks for at most one checkpoint at a time: a request made
while one is already waiting is the same request. The checkpoint goes
through the database's own writer, in its line, like any other write.
A waiting request holds the database weakly: it must not keep alive a
database nobody else holds any more.
"""
def __init__(self) -> None:
self._state = threading.Condition()
self._due: OrderedDict[int, weakref.ref[MainDatabase]] = OrderedDict()
self._running = 0
self._thread: threading.Thread | None = None
def request(self, database: "MainDatabase") -> None:
with self._state:
self._due.setdefault(id(database), weakref.ref(database))
if self._thread is None or not self._thread.is_alive():
self._thread = threading.Thread(
target=self._run,
name="one-memory-checkpoint",
daemon=True,
)
self._thread.start()
self._state.notify_all()
def forget(self, database: "MainDatabase") -> None:
with self._state:
self._due.pop(id(database), None)
self._state.notify_all()
def wait_idle(self, timeout: float) -> bool:
"""Whether every requested checkpoint has run within `timeout` seconds."""
with self._state:
return self._state.wait_for(lambda: not self._due and not self._running, timeout)
def _run(self) -> None:
while True:
with self._state:
self._state.wait_for(lambda: bool(self._due))
_key, reference = self._due.popitem(last=False)
database = reference()
if database is None:
self._state.notify_all()
continue
self._running += 1
try:
database._cadence_checkpoint()
except Exception: # noqa: BLE001 - maintenance must not end the thread
logger.warning(
"main_database_checkpoint_failed file=%s", database.path.name, exc_info=True
)
finally:
del database
with self._state:
self._running -= 1
self._state.notify_all()
_checkpointer = _Checkpointer()
def wait_for_cadence_checkpoints(timeout: float = 5.0) -> bool:
"""Wait for the cadence checkpoints already requested; for tests and shutdown."""
return _checkpointer.wait_idle(timeout)
def _locked() -> sqlite3.OperationalError:
# Callers tell a busy database from a broken one by these, as they would
# for SQLite's own refusal.
@@ -614,13 +683,23 @@ class MainDatabase:
return None
def _maybe_checkpoint_after_write(self) -> None:
"""Checkpoint on a write cadence without opening another connection.
"""Count a write, and hand a due cadence checkpoint to the checkpointer.
SQLite's default autocheckpoint only runs when the writer itself
crosses the page threshold. Production hit a state where the WAL grew
to 13GB, so the main database needs its own bounded, observable
maintenance path. This runs after the caller's checkout has been reset
and only re-enters the same serialized writer queue.
maintenance path: every `checkpoint_write_interval()` writes or
`MAIN_DATABASE_CHECKPOINT_SECONDS`, whichever comes first.
The checkpoint itself used to run right here, on the thread that had
just written, as one more turn in the writer's line. That thread was
an import heartbeat or a durable lease renewal as often as not, and
it then waited behind the whole line a second time before it could
return. The idle TRUNCATE (#278) cannot replace the cadence: under a
sustained import the database is never idle, and that is exactly when
the WAL grows. So the cadence stays, and one checkpointer thread runs
it (`_Checkpointer`); the writer stays the only connection that
writes, and no caller waits for maintenance it did not ask for.
"""
now = time.monotonic()
with self._state:
@@ -632,8 +711,12 @@ class MainDatabase:
if due:
self._writes_since_checkpoint = 0
self._last_checkpoint_at = now
if not due:
return
if due:
_checkpointer.request(self)
def _cadence_checkpoint(self) -> None:
"""One PASSIVE checkpoint through the writer, and the WAL alert after it."""
now = time.monotonic()
try:
busy, log_frames, checkpointed = self._checkpoint_from_maintenance("PASSIVE")
except sqlite3.Error:
@@ -707,6 +790,8 @@ class MainDatabase:
def close(self) -> None:
"""Close every idle connection now; ones in use close when handed back."""
# A checkpoint still waiting would open the writer again after this.
_checkpointer.forget(self)
with self._state:
self._generation += 1
self._identity = None
@@ -14,7 +14,7 @@ from pathlib import Path
from typing import Literal, cast
HY_MEMORY_DISTRIBUTION = "hy-memory"
HY_MEMORY_VERSION = "2.0.13"
HY_MEMORY_VERSION = "2.0.14"
HY_MEMORY_NAMESPACE = "governed-v2"
DiagnosticLogLevel = Literal["error", "warning", "info", "debug"]
@@ -503,7 +503,7 @@ def _ensure_coding_schema(path: Path) -> None:
CodingMemoryStore,
)
# Relies on the pinned SDK's internals (hy-memory==2.0.13 is pinned exactly).
# Relies on the pinned SDK's internals (hy-memory==2.0.14 is pinned exactly).
store = CodingMemoryStore.__new__(CodingMemoryStore)
store._db_path = str(path)
store._conn = None
+188 -8
View File
@@ -1,6 +1,7 @@
import logging
import re
import sqlite3
from collections.abc import Iterable
from contextlib import AbstractContextManager, nullcontext
from datetime import UTC, datetime
from enum import StrEnum
@@ -18,13 +19,27 @@ from one_memory.imports.adapters import ScanPhase, SourceCursor
#: the drift would only surface on a fresh database.
#:
#: This runs on SQLite today, so the SQLite rendering is used.
from one_memory.memory_server.migrations import import_jobs_ddl
from one_memory.memory_server.migrations import (
import_job_lookup_indexes_ddl,
import_jobs_ddl,
)
logger = logging.getLogger("one_memory.imports.jobs")
JOB_SCHEMA = import_jobs_ddl("sqlite")
#: Migration 63, applied by the store as well so a running home gets it.
JOB_LOOKUP_INDEXES = import_job_lookup_indexes_ddl("sqlite")
MAX_LIVE_BACKLOG_BYTES = 256 * 1024
#: Finished live syncs kept per (user, source). Live sync opens a job for every
#: change it imports, and nothing removed them: hundreds a day on an active
#: machine. The App shows the latest job per source and the active ones; twenty is
#: that plus enough recent history to read a failure back.
SYNC_JOBS_KEPT_PER_SOURCE = 20
#: Rows one pruning transaction may delete, so it holds the single writer for
#: a few milliseconds at most however large the table grew.
PRUNE_BATCH_ROWS = 200
class ImportStatus(StrEnum):
PENDING = "pending"
@@ -36,6 +51,12 @@ class ImportStatus(StrEnum):
CANCELLED = "cancelled"
#: States no job ever leaves (`ImportJobStore._TRANSITIONS`). Failed and
#: interrupted are not among them: both can be resumed, and both still hold the
#: source's one active slot.
FINISHED_STATUSES = (ImportStatus.COMPLETED, ImportStatus.CANCELLED)
class ImportJob(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
@@ -272,6 +293,9 @@ class ImportJobStore:
WHERE status IN ('pending', 'running', 'paused', 'interrupted', 'failed')
"""
)
for statement in JOB_LOOKUP_INDEXES.split(";"):
if statement.strip():
connection.execute(statement)
def create(
self,
@@ -334,8 +358,8 @@ class ImportJobStore:
read again.
"""
opened: list[ImportJob] = []
for job in self.list_jobs(status=ImportStatus.FAILED):
if job.operation != "history" or job.stored != 0:
for job in self.find_jobs(operation="history", statuses=(ImportStatus.FAILED,)):
if job.stored != 0:
continue
reasons = {issue.reason for issue in self.list_issues(job.job_id)}
if not reasons or not reasons <= self._UNPUBLISHED_SNAPSHOT_FAILURES:
@@ -799,16 +823,172 @@ class ImportJobStore:
return self.require(job_id)
def list_jobs(self, *, status: ImportStatus | None = None) -> list[ImportJob]:
"""Every job, oldest first. Hot paths ask `find_jobs` a narrower question."""
return self.find_jobs(statuses=(status,) if status is not None else None)
def find_jobs(
self,
*,
user_id: str | None = None,
source_id: str | None = None,
operation: Literal["history", "sync"] | None = None,
statuses: Iterable[ImportStatus] | None = None,
newest_first: bool = False,
limit: int | None = None,
) -> list[ImportJob]:
"""The jobs matching every given filter, oldest first unless asked otherwise.
The filters are SQL, served by `import_jobs_lane` / `import_jobs_status`
(migration 63). Reading the whole table and filtering here was what
three bulk threads and three request threads were all inside, for tens
of seconds, while the loop waited on them.
"""
clauses: list[str] = []
parameters: list[str | int] = []
if user_id is not None:
clauses.append("user_id = ?")
parameters.append(user_id)
if source_id is not None:
clauses.append("source_id = ?")
parameters.append(source_id)
if operation is not None:
clauses.append("operation = ?")
parameters.append(operation)
if statuses is not None:
wanted = tuple(dict.fromkeys(ImportStatus(status) for status in statuses))
if not wanted:
return []
clauses.append(f"status IN ({', '.join('?' for _ in wanted)})")
parameters.extend(wanted)
query = "SELECT * FROM import_jobs"
parameters: tuple[str, ...] = ()
if status is not None:
query += " WHERE status = ?"
parameters = (status,)
query += " ORDER BY created_at, job_id"
if clauses:
query += " WHERE " + " AND ".join(clauses)
query += (
" ORDER BY created_at DESC, job_id DESC"
if newest_first
else " ORDER BY created_at, job_id"
)
if limit is not None:
if limit < 1:
raise ValueError("limit must be positive")
query += " LIMIT ?"
parameters.append(limit)
with self._connect() as connection:
rows = connection.execute(query, parameters).fetchall()
return [self._to_job(row) for row in rows]
def latest_job(
self,
*,
user_id: str,
source_id: str,
operation: Literal["history", "sync"] | None = None,
statuses: Iterable[ImportStatus] | None = None,
) -> ImportJob | None:
jobs = self.find_jobs(
user_id=user_id,
source_id=source_id,
operation=operation,
statuses=statuses,
newest_first=True,
limit=1,
)
return jobs[0] if jobs else None
def prune_finished_sync_jobs(
self,
*,
keep: int = SYNC_JOBS_KEPT_PER_SOURCE,
max_rows: int = PRUNE_BATCH_ROWS,
) -> int:
"""Delete finished live-sync jobs beyond the newest `keep` of each source.
One bounded transaction: at most `max_rows` jobs, with their issues and
skip counts. Returns how many jobs went; fewer than `max_rows` means
nothing is left to prune, so calling again until then finishes the job.
Only `sync` jobs in a state nothing leaves (completed, cancelled) are
ever deleted. Failed and interrupted jobs hold the source's active slot
and can be resumed; history jobs are named by connection receipts, by
the onboarding gate and by the first-import review, and there are a
handful per source, so they are all kept. Cursors live in their own
tables keyed by file, not by job, so a pruned job takes no position
with it. The newest job of every source is always among those kept,
which is the one the App and live sync health name.
"""
if keep < 1:
raise ValueError("keep must be positive")
if max_rows < 1:
raise ValueError("max_rows must be positive")
finished = tuple(FINISHED_STATUSES)
placeholders = ", ".join("?" for _ in finished)
deleted = 0
with self._connect(writing=True) as connection:
lanes = connection.execute(
f"""
SELECT user_id, source_id, COUNT(*) AS finished
FROM import_jobs
WHERE operation = 'sync' AND status IN ({placeholders})
GROUP BY user_id, source_id
HAVING COUNT(*) > ?
""",
(*finished, keep),
).fetchall()
for lane in lanes:
if deleted >= max_rows:
break
# The oldest job still kept: everything strictly older goes.
boundary = connection.execute(
f"""
SELECT created_at, job_id FROM import_jobs
WHERE user_id = ? AND source_id = ? AND operation = 'sync'
AND status IN ({placeholders})
ORDER BY created_at DESC, job_id DESC
LIMIT 1 OFFSET ?
""",
(lane["user_id"], lane["source_id"], *finished, keep - 1),
).fetchone()
if boundary is None:
continue
victims = [
str(row["job_id"])
for row in connection.execute(
f"""
SELECT job_id FROM import_jobs
WHERE user_id = ? AND source_id = ? AND operation = 'sync'
AND status IN ({placeholders})
AND (created_at < ? OR (created_at = ? AND job_id < ?))
ORDER BY created_at, job_id
LIMIT ?
""",
(
lane["user_id"],
lane["source_id"],
*finished,
boundary["created_at"],
boundary["created_at"],
boundary["job_id"],
max_rows - deleted,
),
)
]
if not victims:
continue
marks = ", ".join("?" for _ in victims)
# Foreign keys cascade these too; deleting them by name keeps
# the outcome the same on a connection that has them off.
connection.execute(f"DELETE FROM import_issues WHERE job_id IN ({marks})", victims)
connection.execute(
f"DELETE FROM import_skip_reason_counts WHERE job_id IN ({marks})", victims
)
removed = connection.execute(
f"DELETE FROM import_jobs WHERE job_id IN ({marks}) "
f"AND operation = 'sync' AND status IN ({placeholders})",
(*victims, *finished),
)
deleted += removed.rowcount
return deleted
def get_source_cursors(
self,
*,
@@ -28,7 +28,7 @@ from one_memory.imports.adapters import (
)
from one_memory.imports.jobs import ImportJob, ImportJobStore, ImportStatus
from one_memory.imports.supervisor import ImportSupervisor
from one_memory.user_scope import DEFAULT_USER_ID, lane_key, split_lane
from one_memory.user_scope import DEFAULT_USER_ID, split_lane
#: How long an automatic retry will wait for a wedged worker to notice cancel.
ABANDON_TIMEOUT_SECONDS = 5.0
@@ -249,13 +249,15 @@ class PollingLiveSyncSupervisor:
),
)
return None
source_jobs = await in_the_background(self.job_store.list_jobs)
# This lane's unfinished jobs only: the newest unfinished sync, and
# whatever history shares its cursor. Asked in SQL; listing every
# job ever run and filtering here was the poll's biggest read.
source_jobs = await in_the_background(self._lane_jobs, source_id)
active_sync = next(
(
job
for job in reversed(source_jobs)
if lane_key(job.user_id, job.source_id) == source_id
and job.operation == "sync"
if job.operation == "sync"
and job.status
in {
ImportStatus.PENDING,
@@ -275,7 +277,9 @@ class PollingLiveSyncSupervisor:
active_sync.job_id
):
attempted_at = datetime.now(UTC)
self.import_supervisor.enqueue(active_sync.job_id)
await self.import_supervisor.enqueue_off_loop(
active_sync.job_id, job=active_sync
)
if not wait_for_completion:
self._observe_sync(source_id, attempted_at, active_sync.job_id)
return active_sync
@@ -295,22 +299,20 @@ class PollingLiveSyncSupervisor:
# by a process restart between yield and claim. Enqueue is
# the operation that actually creates the task.
attempted_at = datetime.now(UTC)
self.import_supervisor.resume(active_sync.job_id)
self.import_supervisor.enqueue(active_sync.job_id)
resumed = await self.import_supervisor.resume_off_loop(active_sync.job_id)
await self.import_supervisor.enqueue_off_loop(active_sync.job_id, job=resumed)
completed = await self.import_supervisor.wait(active_sync.job_id)
self._record_outcome(source_id, attempted_at, completed)
return completed
competing = [
job
for job in source_jobs
if lane_key(job.user_id, job.source_id) == source_id
and job.operation != "sync"
if job.operation != "sync"
and job.status
in {ImportStatus.PENDING, ImportStatus.RUNNING, ImportStatus.INTERRUPTED}
]
bounded_history_incomplete = any(
lane_key(job.user_id, job.source_id) == source_id
and job.operation == "history"
job.operation == "history"
and job.history_after is not None
and job.status is not ImportStatus.COMPLETED
for job in source_jobs
@@ -354,6 +356,38 @@ class PollingLiveSyncSupervisor:
# automatically after the realtime slice completes.
return await self._run_sync(source_id, wait_for_completion=wait_for_completion)
def _lane_jobs(self, source_id: str) -> list[ImportJob]:
"""The lane's jobs `_sync` decides on, oldest first.
Everything not completed, of either operation: an unfinished sync to
pick up, a history that competes for the cursor, and a bounded history
that has not completed yet (which includes a cancelled one). Finished
syncs, the rows that grow without end, are never read.
"""
user_id, bare_source_id = split_lane(source_id)
unfinished = tuple(
status for status in ImportStatus if status is not ImportStatus.COMPLETED
)
return [
*self.job_store.find_jobs(
user_id=user_id,
source_id=bare_source_id,
operation="history",
statuses=unfinished,
),
*self.job_store.find_jobs(
user_id=user_id,
source_id=bare_source_id,
operation="sync",
statuses=(
ImportStatus.PENDING,
ImportStatus.RUNNING,
ImportStatus.PAUSED,
ImportStatus.INTERRUPTED,
),
),
]
async def _has_work(self, source_id: str) -> bool:
"""Ask the source whether a scan would write anything, before taking a job out.
@@ -452,7 +486,7 @@ class PollingLiveSyncSupervisor:
),
)
self._active_jobs[source_id] = job.job_id
self.import_supervisor.enqueue(job.job_id)
await self.import_supervisor.enqueue_off_loop(job.job_id, job=job)
if not wait_for_completion:
self._observe_sync(source_id, attempted_at, job.job_id)
return job
@@ -23,6 +23,7 @@ from pathlib import Path
from watchfiles import awatch
from one_memory.background_work import in_the_background
from one_memory.diagnostic_log import diagnostic_event
from one_memory.imports.jobs import ImportStatus
from one_memory.imports.live_sync import PollingLiveSyncSupervisor
@@ -90,7 +91,8 @@ class FileWatchLiveSync:
# turn that caused setup. The watcher cannot observe a change
# from before it existed, so immediately run the same bounded
# trigger once instead of waiting for the 60-second backstop.
self._queue_changes(set(self.roots.values()))
# The roots are this watcher's own sources: nothing to resolve.
self._queue_sources(tuple(self.roots), count=len(self.roots))
async def shutdown(self) -> None:
task = self._task
@@ -139,28 +141,54 @@ class FileWatchLiveSync:
observed_at: datetime | None = None,
) -> tuple[str, ...]:
"""Queue affected sources and wait for their coalesced work to settle."""
workers = self._queue_changes(changed, observed_at=observed_at)
workers = await self._queue_changes(changed, observed_at=observed_at)
await asyncio.gather(*(asyncio.shield(worker) for worker in workers.values()))
return tuple(workers)
def _queue_changes(
def sources_for(self, changed: set[Path]) -> tuple[str, ...]:
"""The sources owning `changed`, in path order. Resolves every path."""
return tuple(
dict.fromkeys(
source
for source in (self.source_for(path) for path in sorted(changed))
if source is not None
)
)
async def _queue_changes(
self, changed: set[Path], *, observed_at: datetime | None = None
) -> dict[str, asyncio.Task[None]]:
"""Attribute a batch of changed paths to sources and queue them.
Attributing resolves each path, a `stat` per component, and a busy
host reports hundreds of paths at once. On the loop that was the main
thread inside `source_for` while nothing else was answered, so the
resolving happens off it and only the queueing here.
"""
if self._stop.is_set():
return {}
observed = observed_at or datetime.now(UTC)
sources = await in_the_background(self.sources_for, changed)
return self._queue_sources(sources, count=len(changed), observed_at=observed)
def _queue_sources(
self,
sources: tuple[str, ...],
*,
count: int,
observed_at: datetime | None = None,
) -> dict[str, asyncio.Task[None]]:
if self._stop.is_set():
return {}
observed = observed_at or datetime.now(UTC)
workers: dict[str, asyncio.Task[None]] = {}
for source_id in dict.fromkeys(
source
for source in (self.source_for(path) for path in sorted(changed))
if source is not None
):
for source_id in sources:
diagnostic_event(
level="debug",
component="import.live_watch",
event="live_sync.hook_received",
outcome="queued",
details={"source_id": source_id, "count": len(changed)},
details={"source_id": source_id, "count": count},
)
self._pending[source_id] = max(self._pending.get(source_id, observed), observed)
worker = self._workers.get(source_id)
@@ -239,7 +267,7 @@ class FileWatchLiveSync:
stop_event=self._stop,
ignore_permission_denied=True,
):
self._queue_changes({Path(path) for _change, path in changes})
await self._queue_changes({Path(path) for _change, path in changes})
except asyncio.CancelledError:
raise
except Exception:
@@ -1,6 +1,7 @@
import asyncio
from collections.abc import Awaitable, Callable, Mapping
from contextlib import suppress
from functools import partial
from time import monotonic
from typing import Literal
@@ -82,6 +83,10 @@ class ImportSupervisor:
)
self._tasks: dict[str, asyncio.Task[None]] = {}
self._drains: dict[str, asyncio.Task[None]] = {}
#: Pause and cancel settle a stopped job with a read and a transition.
#: Made on the loop, two of them could not interleave; made off it,
#: they are serialized here so they still land in the order they woke.
self._settling = asyncio.Lock()
def register_adapter(self, adapter: SourceAdapter, *, user_id: str = "local") -> None:
"""Adapters are keyed by lane: the source id for `local`, `source@user`
@@ -116,11 +121,44 @@ class ImportSupervisor:
raise UnknownSourceError(lane)
if self.source_enabled is not None and not self.source_enabled(lane):
return job
existing = self._tasks.get(job_id)
self._start(job)
return job
async def enqueue_off_loop(self, job_id: str, *, job: ImportJob | None = None) -> ImportJob:
"""`enqueue`, with its job read and its grant check made off the loop.
Both are database reads, and the grant check resolves the source root
on disk. Live sync enqueues from its poll, so on the loop they were
the main thread waiting for a reader behind an import peak. `job` is
the row the caller has just read, when it has one.
"""
if job_id in self._drains:
raise InvalidJobTransitionError("import_cleanup_in_progress")
def admit() -> tuple[ImportJob, bool]:
current = job if job is not None and job.job_id == job_id else None
if current is None:
current = self.job_store.require(job_id)
lane = self.lane_of(current)
return current, self.source_enabled is None or self.source_enabled(lane)
current, enabled = await in_the_background(admit)
lane = self.lane_of(current)
if lane not in self.adapters:
raise UnknownSourceError(lane)
if job_id in self._drains:
raise InvalidJobTransitionError("import_cleanup_in_progress")
if enabled:
self._start(current)
return current
def _start(self, job: ImportJob) -> None:
lane = self.lane_of(job)
existing = self._tasks.get(job.job_id)
if existing is None or existing.done():
self._tasks[job_id] = asyncio.create_task(
self._tasks[job.job_id] = asyncio.create_task(
self._execute(
job_id,
job.job_id,
source_id=lane,
operation=job.operation,
)
@@ -130,10 +168,24 @@ class ImportSupervisor:
component="import.supervisor",
event="import.queued",
outcome="queued",
job_id=job_id,
job_id=job.job_id,
details={"source_id": job.source_id, "mode": job.operation},
)
return job
async def resume_interrupted_off_loop(self) -> None:
"""`resume_interrupted` for the running Core: every read and write off the loop."""
for status in (ImportStatus.INTERRUPTED, ImportStatus.PENDING):
jobs = await in_the_background(partial(self.job_store.find_jobs, statuses=(status,)))
for job in jobs:
if self.lane_of(job) not in self.adapters:
continue
if job.status is not ImportStatus.PENDING:
if self.source_enabled is not None and not await in_the_background(
self.source_enabled, self.lane_of(job)
):
continue
job = await in_the_background(self.job_store.queue_for_resume, job.job_id)
await self.enqueue_off_loop(job.job_id, job=job)
def resume_interrupted(self) -> None:
# A process restart must not place recoverable work behind every newly
@@ -166,40 +218,64 @@ class ImportSupervisor:
self.enqueue(job_id)
return queued
async def resume_off_loop(self, job_id: str) -> ImportJob:
"""`resume`, with its reads and its write made off the event loop."""
if job_id in self._drains:
raise InvalidJobTransitionError("import_cleanup_in_progress")
queued = await in_the_background(self._queue_resumable, job_id)
if queued.status is not ImportStatus.PENDING:
return queued
return await self.enqueue_off_loop(job_id, job=queued)
def _queue_resumable(self, job_id: str) -> ImportJob:
job = self.job_store.require(job_id)
if job.status not in {
ImportStatus.PENDING,
ImportStatus.PAUSED,
ImportStatus.INTERRUPTED,
ImportStatus.FAILED,
}:
return job
return self.job_store.queue_for_resume(job_id)
async def wait(self, job_id: str) -> ImportJob:
task = self._tasks.get(job_id)
if task is not None:
await asyncio.shield(task)
return self.job_store.require(job_id)
return await in_the_background(self.job_store.require, job_id)
async def pause(self, job_id: str) -> ImportJob:
job = self.job_store.require(job_id)
job = await in_the_background(self.job_store.require, job_id)
if job.status in {ImportStatus.PAUSED, ImportStatus.INTERRUPTED}:
return job
await self._cancel_task(job_id)
current = self.job_store.require(job_id)
if current.status is ImportStatus.COMPLETED:
return current
return self.job_store.transition(job_id, ImportStatus.PAUSED)
async with self._settling:
return await in_the_background(self._settle_stopped, job_id, ImportStatus.PAUSED)
async def cancel(self, job_id: str) -> ImportJob:
job = self.job_store.require(job_id)
job = await in_the_background(self.job_store.require, job_id)
if job.status is ImportStatus.CANCELLED:
return job
await self._cancel_task(job_id)
async with self._settling:
return await in_the_background(self._settle_stopped, job_id, ImportStatus.CANCELLED)
def _settle_stopped(self, job_id: str, target: ImportStatus) -> ImportJob:
"""A stopped runner's job moves to `target`, unless it completed first."""
current = self.job_store.require(job_id)
if current.status is ImportStatus.COMPLETED:
return current
return self.job_store.transition(job_id, ImportStatus.CANCELLED)
return self.job_store.transition(job_id, target)
async def shutdown(self) -> None:
running = [
job
for job in self.job_store.list_jobs(status=ImportStatus.RUNNING)
if job.job_id in self._tasks
]
for job in running:
self.job_store.interrupt_running(job.job_id)
mine = frozenset(self._tasks)
def interrupt_mine() -> None:
for job in self.job_store.find_jobs(statuses=(ImportStatus.RUNNING,)):
if job.job_id in mine:
self.job_store.interrupt_running(job.job_id)
await in_the_background(interrupt_mine)
await asyncio.gather(
*(self._cancel_task(job_id) for job_id in tuple(self._tasks)),
)
@@ -355,9 +431,7 @@ class ImportSupervisor:
try:
await asyncio.wait_for(asyncio.shield(task), timeout)
except TimeoutError:
current = self.job_store.require(job_id)
if current.status is ImportStatus.RUNNING:
self.job_store.interrupt_running(job_id)
await in_the_background(self._interrupt_if_running, job_id)
return False
except asyncio.CancelledError:
# The worker noticed cancel and exited. A cancel of this
@@ -365,10 +439,16 @@ class ImportSupervisor:
caller = asyncio.current_task()
if caller is not None and caller.cancelling():
raise
# A read and a write on the single writer: off the loop. Live sync
# calls this from its poll, and on the loop it was the main thread
# waiting its turn for the writer behind an import peak.
await in_the_background(self._interrupt_if_running, job_id)
return True
def _interrupt_if_running(self, job_id: str) -> None:
current = self.job_store.require(job_id)
if current.status is ImportStatus.RUNNING:
self.job_store.interrupt_running(job_id)
return True
async def _cancel_task(self, job_id: str) -> None:
drain = self._drains.get(job_id)
@@ -28,10 +28,11 @@ from pathlib import Path
from typing import Any, Literal
from uuid import uuid4
from one_memory.background_work import in_the_background
from one_memory.background_work import in_lease_renewal, in_the_write_queue
from one_memory.conversation_processing import TURN_IDLE_FLUSH_SECONDS, turn_batches
from one_memory.database_access import main_database
from one_memory.diagnostic_log import diagnostic_event
from one_memory.exception_reporting import report_exception
from one_memory.imports.models import EvidenceOrigin, EvidenceRecord
from one_memory.imports.storage import EvidenceStore
from one_memory.memory_server.contracts import (
@@ -451,6 +452,14 @@ class _LeaseRenewalFatalError(RuntimeError):
"""A non-transient renewal failure that must stop the worker."""
def _drain_exit_code(error: BaseException) -> str:
"""A stable, lowercase code for why the drain exited; never message text."""
if isinstance(error, _LeaseRenewalFatalError):
return "lease_renewal_failed"
name = re.sub(r"(?<!^)(?=[A-Z])", "_", type(error).__name__).lower()
return name if re.fullmatch(r"[a-z_]{1,64}", name) else "drain_error"
@dataclass(frozen=True)
class UnknownWriteJob:
"""An `unknown` row that still needs an external inquiry.
@@ -2562,14 +2571,14 @@ async def execute_job(
# Every store call here is synchronous SQLite work on the single writer or
# a reader; made from the loop it would block health checks and the API for
# as long as the write lock is held.
request = await in_the_background(queue.payload_for, job_id)
request = await in_the_write_queue(queue.payload_for, job_id)
if request is None:
# A claimed row whose payload is missing or unreadable (reaped while
# still claimable, corrupted, or a foreign row) must not be left
# `running`: nothing was sent, so it is a terminal failure rather than
# an unknown, and the lease must be released so it is not held until it
# lapses.
await in_the_background(
await in_the_write_queue(
queue.settle,
job_id,
state=FAILED,
@@ -2579,9 +2588,9 @@ async def execute_job(
)
return
try:
records = await in_the_background(evidence_for_request, evidence, request)
records = await in_the_write_queue(evidence_for_request, evidence, request)
except LookupError as error:
await in_the_background(
await in_the_write_queue(
queue.settle,
job_id,
state=FAILED,
@@ -2618,7 +2627,7 @@ async def execute_job(
source_id = lane_key(next(iter(users), DEFAULT_USER_ID), source_id)
async def mark() -> bool:
return await in_the_background(queue.mark_invoked, job_id, owner=owner)
return await in_the_write_queue(queue.mark_invoked, job_id, owner=owner)
if admit_hy_invocation is not None:
admitted = await admit_hy_invocation(source_id, requires_generation, mark)
@@ -2633,7 +2642,7 @@ async def execute_job(
raise RuntimeError("durable_write_lease_lost_before_invocation")
hy_invoked = True
attempt = await in_the_background(queue.sdk_attempt, job_id)
attempt = await in_the_write_queue(queue.sdk_attempt, job_id)
window = model_failures() if model_failures is not None else nullcontext(None)
provider: ModelAttempt | None = None
try:
@@ -2675,7 +2684,7 @@ async def execute_job(
}
and not hy_invoked
):
await in_the_background(queue.release_unstarted, job_id, owner=owner)
await in_the_write_queue(queue.release_unstarted, job_id, owner=owner)
return
provider_failure = provider.failure if provider is not None else None
if hy_invoked and provider_failure is not None:
@@ -2683,7 +2692,7 @@ async def execute_job(
# gone, a provider that is down): the job is fine and waits for
# the provider, in its place, without spending a retry. The
# breaker paces when it is tried again.
await in_the_background(
await in_the_write_queue(
queue.requeue_for_model, job_id, error_code=provider_failure, owner=owner
)
logger.info(
@@ -2712,7 +2721,7 @@ async def execute_job(
elif not hy_invoked and code in MODEL_NOT_READY_CODES:
state = WAITING_FOR_MODEL
if state is not None:
await in_the_background(
await in_the_write_queue(
queue.settle,
job_id,
state=state,
@@ -2724,7 +2733,7 @@ async def execute_job(
# Anything else is this job's own trouble: content the model or
# the SDK refused, a reply that did not parse, a check that failed
# after the write. Retried a few times, then `failed`.
state = await in_the_background(
state = await in_the_write_queue(
queue.retry_or_fail, job_id, error_code=code, owner=owner
)
# Never `logger.exception` here: it prints the exception's message,
@@ -2751,7 +2760,7 @@ async def execute_job(
error_chain_text(error),
)
return
await in_the_background(
await in_the_write_queue(
queue.settle,
job_id,
state=COMPLETED,
@@ -2760,13 +2769,13 @@ async def execute_job(
sdk_memory_ids=memory_ids,
)
release = getattr(evidence, "release_sdk_staging", None)
durable_receipt = await in_the_background(queue.sdk_receipt, job_id)
durable_receipt = await in_the_write_queue(queue.sdk_receipt, job_id)
if (
callable(release)
and durable_receipt is not None
and (durable_receipt.raw_ids or durable_receipt.memory_ids)
):
await in_the_background(
await in_the_write_queue(
release,
tuple(record.evidence_id for record in records),
)
@@ -2891,6 +2900,16 @@ class DurableWriteDrain:
#: a minute against a provider that refused every one (#245).
self.model_claim_allowance = model_claim_allowance
self._last_unknown_reconcile: datetime | None = None
#: What `run_supervised` says about the drain: None before it was
#: first started, then `running`, `restarting` (it exited on an error
#: and waits out its backoff) or `stopped`. Shown by memory-processing.
self.liveness: Literal["running", "restarting", "stopped"] | None = None
#: The stable code of the last unexpected exit, kept after a restart.
self.last_error_code: str | None = None
self.restarts = 0
self._stop_requested = False
#: Wakes a restart backoff, so `stop()` does not wait it out.
self._stopping = asyncio.Event()
def claim(
self,
@@ -3218,7 +3237,7 @@ class DurableWriteDrain:
# Claim is a blocking SQLite write on the single writer connection.
# Run it off the loop, like every other DB call in this chain; a
# busy writer would otherwise stall the whole event loop.
ids = await in_the_background(
ids = await in_the_write_queue(
self.claim,
limit=limit,
budget_ms=self.claim_budget_ms,
@@ -3231,10 +3250,10 @@ class DurableWriteDrain:
await asyncio.gather(*(self._run_claimed_job(job_id) for job_id in ids))
async def _run_claimed_job(self, job_id: str) -> None:
if not await in_the_background(self.still_owns, job_id):
if not await in_the_write_queue(self.still_owns, job_id):
logger.warning("durable_write_drain_lease_lost_before_execution job_id=%s", job_id)
return
deadline = await in_the_background(self.lease_deadline, job_id)
deadline = await in_the_write_queue(self.lease_deadline, job_id)
if deadline is None:
return
renewal = asyncio.create_task(self._renew_loop(job_id, deadline))
@@ -3261,11 +3280,9 @@ class DurableWriteDrain:
"durable_write_drain_job_ceiling_exceeded job_id=%s",
job_id,
)
await in_the_background(
self.queue.settle_worker_error,
await self._settle_worker_error(
job_id,
error_code="durable_work_ceiling_exceeded",
owner=self.owner,
)
except Exception as error:
logger.error(
@@ -3273,12 +3290,15 @@ class DurableWriteDrain:
job_id,
type(error).__name__,
)
report_exception(
"memory.write_queue",
"memory.write_queue.executor_failed",
error,
)
self._record_database_error(error)
await in_the_background(
self.queue.settle_worker_error,
await self._settle_worker_error(
job_id,
error_code="durable_executor_error",
owner=self.owner,
)
finally:
execution.cancel()
@@ -3287,9 +3307,39 @@ class DurableWriteDrain:
with contextlib.suppress(asyncio.CancelledError, Exception):
await task
async def _settle_worker_error(self, job_id: str, *, error_code: str) -> None:
"""Best-effort settlement must not kill the long-lived drain.
The row still has a durable lease when this fallback runs. If the
single SQLite writer is busy, waiting and retrying here only extends
the same contention that made the executor fail. Leave the row where
it is instead: lease recovery later moves a never-invoked job back to
pending and a possibly-invoked job to unknown, preserving the
no-blind-retry boundary.
Only transient lock/busy refusals are deferred. A malformed query or
other programming error still escapes to the outer per-job fence.
"""
try:
await in_the_write_queue(
self.queue.settle_worker_error,
job_id,
error_code=error_code,
owner=self.owner,
)
except sqlite3.OperationalError as error:
if "locked" not in str(error).lower() and "busy" not in str(error).lower():
raise
self._sample_database_failure("settle_worker_error", error)
logger.warning(
"durable_write_worker_settlement_deferred job_id=%s error_type=%s",
job_id,
type(error).__name__,
)
async def _execute_with_ceiling(self, job_id: str) -> None:
ceiling = (
await in_the_background(self.work_ceiling)
await in_the_write_queue(self.work_ceiling)
if callable(self.work_ceiling)
else self.work_ceiling
)
@@ -3324,7 +3374,33 @@ class DurableWriteDrain:
finished = {task for task in active if task.done()}
active.difference_update(finished)
for task in finished:
await task
try:
await task
except asyncio.CancelledError:
raise
except _LeaseRenewalFatalError:
# Renewal is the ownership fence. If it is broken,
# continuing could let another worker reclaim a job
# while this process is still executing it.
raise
except Exception as error:
# A job task is a fault-containment boundary. Its row
# retains the durable lease and is recovered according
# to hy_invoked; killing this only consumer would strand
# every unrelated write behind it.
self._record_database_error(error)
logger.error(
"durable_write_drain_job_task_failed task_name=%s error_type=%s",
task.get_name(),
type(error).__name__,
)
# With its stack: a type name alone cannot tell one
# stray failure from a bug that fails every job.
report_exception(
"memory.write_queue",
"memory.write_queue.job_task_failed",
error,
)
if self.budget is not None and work_deadline is not None:
now = _time.monotonic()
# Always admit the first unit of a cycle even when the
@@ -3340,7 +3416,7 @@ class DurableWriteDrain:
try:
available = self.max_concurrent_sources - len(active)
ids = (
await in_the_background(
await in_the_write_queue(
self.claim,
limit=available,
budget_ms=self.claim_budget_ms,
@@ -3349,7 +3425,12 @@ class DurableWriteDrain:
else ()
)
for job_id in ids:
active.add(asyncio.create_task(self._run_claimed_job(job_id)))
active.add(
asyncio.create_task(
self._run_claimed_job(job_id),
name=f"durable-write:{job_id}",
)
)
executed = len(ids)
self._database_failure_streaks.pop("claim", None)
except asyncio.CancelledError:
@@ -3447,7 +3528,79 @@ class DurableWriteDrain:
await asyncio.gather(*active, return_exceptions=True)
def stop(self) -> None:
self._stop_requested = True
self.running = False
self._stopping.set()
async def run_supervised(
self,
*,
idle_pause: float = 0.05,
restart_backoff: float = 1.0,
max_restart_backoff: float = 60.0,
) -> None:
"""`run_forever`, started again whenever it exits on an error.
The drain is the queue's only consumer, so a drain that died stranded
every write behind it until the next start, and nothing said so: an
unexpected SQLite error on the claim path (disk I/O, a vanished file)
or an exception from a claim gate ended it for good. Each exit is now
reported with its stack, and the drain comes back after a pause that
doubles up to `max_restart_backoff`, back to `restart_backoff` once it
has run for that long. `stop()` and cancellation end it as before.
Ownership across a restart: `run_forever` only returns once every job
task it started has finished (its `finally`), and a task whose lease
renewal broke (`_LeaseRenewalFatalError`) waits for its execution to
return first, so nothing of the old drain is still running a job when
the new one claims. The new one also takes a new lease owner, so a
statement of the old one still in a pool thread can neither renew nor
settle a row under the new drain's name, and the old drain's leases
are only ever recovered by expiry (`reclaim_expired`), which decides
by `hy_invoked` exactly as for a drain that died with its process.
"""
backoff = restart_backoff
while not self._stop_requested:
started = _time.monotonic()
self.liveness = "running"
try:
await self.run_forever(idle_pause=idle_pause)
except asyncio.CancelledError:
self.liveness = "stopped"
raise
except Exception as error:
self.last_error_code = _drain_exit_code(error)
self.restarts += 1
report_exception(
"memory.write_queue",
"memory.write_queue.drain_exited",
error,
context={"restarts": self.restarts},
)
logger.error(
"durable_write_drain_exited error_code=%s restarts=%d chain=%s",
self.last_error_code,
self.restarts,
error_chain_text(error),
)
else:
if self._stop_requested:
break
if self._stop_requested:
break
self.owner = f"durable-drain:{uuid4()}"
if _time.monotonic() - started >= max_restart_backoff:
backoff = restart_backoff
self.liveness = "restarting"
try:
await asyncio.wait_for(self._stopping.wait(), timeout=backoff)
except TimeoutError:
pass
except asyncio.CancelledError:
self.liveness = "stopped"
raise
backoff = min(backoff * 2, max_restart_backoff)
self.liveness = "stopped"
async def _run_idle_maintenance(
self,
@@ -3465,7 +3618,7 @@ class DurableWriteDrain:
persisting provider or user content.
"""
try:
await in_the_background(operation)
await in_the_write_queue(operation)
self._database_failure_streaks.pop(f"maintenance:{operation_name}", None)
except asyncio.CancelledError:
raise
@@ -3493,7 +3646,7 @@ class DurableWriteDrain:
renewed_at = datetime.now(UTC)
remaining = (deadline - renewed_at).total_seconds()
if remaining <= 0 or not await asyncio.wait_for(
in_the_background(self.renew, job_id), timeout=remaining
in_lease_renewal(self.renew, job_id), timeout=remaining
):
return False
deadline = renewed_at + self.lease
@@ -3235,6 +3235,65 @@ async def _062_onboarding_progress(cursor: Any) -> None:
raise
IMPORT_JOB_LOOKUP_INDEXES_MIGRATION_VERSION = 63
def import_job_lookup_indexes_ddl(dialect: str = "mysql") -> str:
"""Index the per-source and per-status import job lookups.
Migration 7 gave SQLite `import_jobs` no index beyond its primary key (the
one-active-job index is partial), so every "this source's jobs" or "the
jobs in this state" question read and sorted the whole table. Live sync
adds a row per change it imports and nothing removed them, so on a machine
a few weeks old that table was thousands of rows read several times a poll.
`import_jobs_lane` serves the lane lookups (one user, one source, one
operation, a status, newest first) and the pruning of finished syncs;
`import_jobs_status` serves restart recovery and the status filters.
MySQL's migration 7 already has a status index and no `operation` column,
so it only gains the ordered source index.
"""
if dialect == "sqlite":
return """
CREATE INDEX IF NOT EXISTS import_jobs_lane
ON import_jobs(user_id, source_id, operation, status, created_at);
CREATE INDEX IF NOT EXISTS import_jobs_status
ON import_jobs(status, created_at);
"""
return """
CREATE INDEX idx_import_jobs_source_created
ON import_jobs(user_id, source_id, created_at);
"""
def _is_duplicate_index(error: Exception) -> bool:
"""MySQL's "index name already there" (1061): the index is in place."""
args: tuple[object, ...] = getattr(error, "args", ())
return bool(args) and args[0] == 1061
async def _063_import_job_lookup_indexes(cursor: Any) -> None:
dialect = _dialect(cursor)
if dialect == "sqlite":
# `operation` was added by the job store, not by migration 7, so a
# database built from the catalog alone may not have it yet. Same
# declaration as the store's own addition.
if "operation" not in await cursor.columns("import_jobs"):
await cursor.execute(
"ALTER TABLE import_jobs ADD COLUMN operation TEXT NOT NULL DEFAULT 'history'"
)
for statement in _split_statements(import_job_lookup_indexes_ddl(dialect)):
await cursor.execute(statement)
return
for statement in _split_statements(import_job_lookup_indexes_ddl(dialect)):
try:
await cursor.execute(statement)
except Exception as error: # noqa: BLE001 - MySQL driver error type varies
if _is_duplicate_index(error):
continue
raise
def _split_statements(sql: str) -> list[str]:
"""Split a multi-statement script, dropping blanks, PRAGMA and comments.
@@ -3541,4 +3600,9 @@ MIGRATIONS: tuple[Migration, ...] = (
_062_onboarding_progress,
("onboarding_state",),
),
Migration(
IMPORT_JOB_LOOKUP_INDEXES_MIGRATION_VERSION,
"import_job_lookup_indexes",
_063_import_job_lookup_indexes,
),
)
@@ -0,0 +1,123 @@
"""Turn-level read projection for SDK raw batches.
HY keeps several complete conversation turns in one L1 raw node so model
writes stay batched. Product readers must not expose that storage batch as
one conversation item: this module gives every contained turn a stable
identity while retaining the parent raw id for traceability.
"""
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass
from one_memory.hy_sdk_types import HySdkRawRecord, raw_turn_messages
@dataclass(frozen=True)
class RawDisplayTurn:
turn_id: str
turn_index: int
turn_count: int
content: str
messages: tuple[tuple[str, str], ...]
def raw_display_turns(record: HySdkRawRecord) -> tuple[RawDisplayTurn, ...]:
"""Split one stored raw batch into user-opened turns.
A user message opens a new turn; assistant/tool/system messages stay with
that user message until the next user message. Legacy content with no user
marker remains one turn so it never disappears from the product.
"""
messages = raw_turn_messages(record.content)
grouped: list[list[tuple[str, str]]] = []
for message in messages:
role, _ = message
has_user = bool(grouped) and any(item[0] == "user" for item in grouped[-1])
if not grouped or (role == "user" and has_user):
grouped.append([message])
else:
grouped[-1].append(message)
if not grouped:
grouped = [[("", record.content)]]
digest = hashlib.sha256(record.raw_id.encode("utf-8")).hexdigest()
count = len(grouped)
return tuple(
RawDisplayTurn(
turn_id=f"raw-turn:{digest}:{index}",
turn_index=index,
turn_count=count,
content=_turn_content(turn),
messages=tuple(turn),
)
for index, turn in enumerate(grouped)
)
def _turn_content(messages: list[tuple[str, str]]) -> str:
return "\n".join(f"[{role}]: {content}" if role else content for role, content in messages)
def raw_prefix_added(
cached: tuple[tuple[str, str | None], ...],
current: tuple[tuple[str, str | None], ...],
cached_total: int,
current_total: int,
) -> int | None:
"""How many raws were only put on top of a cached newest-first listing.
`cached` and `current` are the (raw id, status) of the first page of each
listing. The answer is a count when the current page is the cached one
with new raws before it and nothing else changed; None when anything else
happened (a delete, a status change, a raw dated into the middle), which
needs a full re-listing.
"""
added = current_total - cached_total
if added <= 0 or added > len(current):
return None
kept = current[added:]
if not kept and cached_total:
return None
if kept != cached[: len(kept)]:
return None
return added
_WORD = re.compile(r"\w+")
def _query_terms(query: str) -> frozenset[str]:
"""Words of the query, plus character bigrams of unspaced (CJK) words."""
terms: set[str] = set()
for word in _WORD.findall(query.casefold()):
terms.add(word)
if not word.isascii() and len(word) > 1:
terms.update(word[index : index + 2] for index in range(len(word) - 1))
return frozenset(terms)
def turns_for_hit(turns: tuple[RawDisplayTurn, ...], query: str) -> tuple[RawDisplayTurn, ...]:
"""The turns one semantic raw hit shows, never none while it has turns.
The SDK embeds a whole raw batch, so a vector hit cannot say which of its
turns matched. Every turn containing the query text is shown; when none
does, the turn sharing the most query terms (the earliest on a tie) stands
for the hit. A semantic hit is never dropped for lacking the literal query.
"""
if not turns:
return ()
needle = query.casefold().strip()
if needle:
lexical = tuple(turn for turn in turns if needle in turn.content.casefold())
if lexical:
return lexical
terms = _query_terms(query)
def overlap(turn: RawDisplayTurn) -> tuple[int, int]:
content = turn.content.casefold()
return (sum(1 for term in terms if term in content), -turn.turn_index)
return (max(turns, key=overlap),)
+3 -1
View File
@@ -123,6 +123,7 @@
| `test_import_api.py` | `integration` | 覆盖 resume during cleanup returns conflict without internal error、authorize detect import and revoke api flow、protected api rejects missing token and foreign origin 等 5 个场景。 |
| `test_import_control_races_review.py` | `contract` | Read-only review repro: real runner/store, synthetic delayed adapter cleanup. |
| `test_import_evidence_atomicity.py` | `contract` | 覆盖 counter failure rolls back evidence versions and cursor、same identity cursor rewind is blocked and logged、replaced identity may reset checkpoint 等 20 个场景。 |
| `test_import_job_retention.py` | `integration` | `import_jobs` stays bounded, and the questions asked of it are SQL, not scans. |
| `test_import_oversized_verbatim.py` | `contract` | 覆盖 normal verbatim is unchanged、oversized verbatim is truncated instead of rejected 场景。 |
| `test_import_runner.py` | `contract` | 覆盖 job transitions are explicit、runner keeps per record errors and continues、sync slices reuse the same source listing 等 22 个场景。 |
| `test_import_skip_batching.py` | `contract` | 覆盖 batch limit and tail use fewer commits、batched skips keep their order against the next write、flushes before every nonmatching event 等 14 个场景。 |
@@ -213,6 +214,7 @@
| `test_onboarding_profile.py` | `integration` | R-09:首次安装引导把画像录下来,不问用户一句话。 |
| `test_oversized_file_guard.py` | `contract` | One pass over a source file is bounded by a byte budget, not skipped. |
| `test_parent_watchdog.py` | `integration` | A Core that outlives its launcher makes the next launch impossible. |
| `test_pinned_sdk_raw_search.py` | `integration` | MemHub's raw search against the hy-memory wheel it pins, not the repo source. |
| `test_policy_api_wiring.py` | `integration` | 覆盖 global model permission off does not backfill、backfill puts history on write jobs without scheduling memory tasks、source pause skips history until the source is reenabled 场景。 |
| `test_policy_production_entry.py` | `contract` | 覆盖 production serve passes only supported app options、maintenance serve does not own the runtime 场景。 |
| `test_precipitation_concurrency_setting.py` | `contract` | Rule 2/9: precipitation concurrency is configurable, not hardcoded to 1. |
@@ -321,4 +323,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. |
共 316 个测试文件。
共 318 个测试文件。
@@ -14,8 +14,8 @@ from pathlib import Path
import pytest
PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live")
EXPECTED_TEST_FILE_COUNT = 316
EXPECTED_TEST_FILES_SHA256 = "011482974a26e9d43ba03bb88cbd168e645175beacd96c02ff642bc17dac49da"
EXPECTED_TEST_FILE_COUNT = 318
EXPECTED_TEST_FILES_SHA256 = "df8752566a0bf9e540f68b4948f3f5dbc409bd6836707b1330206e2349d70940"
# Pure transformations, validation models, parity checks and state machines.
# This list is intentionally small: uncertain files default to contract.
@@ -70,6 +70,7 @@ INTEGRATION_FILES = frozenset(
"test_file_import_operations.py",
"test_graph_width_endpoint.py",
"test_import_api.py",
"test_import_job_retention.py",
"test_live_capture.py",
"test_live_watch_grants.py",
"test_log_privacy.py",
@@ -93,6 +94,7 @@ INTEGRATION_FILES = frozenset(
"test_model_profile_context_reset.py",
"test_onboarding_profile.py",
"test_parent_watchdog.py",
"test_pinned_sdk_raw_search.py",
"test_policy_api_wiring.py",
"test_recall_scope_control.py",
"test_retrieval_lifespan.py",
+12 -1
View File
@@ -150,7 +150,18 @@ def test_lifespan_content_backfill_failure_is_visible(tmp_path, monkeypatch) ->
async def scenario() -> None:
async with app.router.lifespan_context(app):
health = await _get_json(app, "/api/v1/health")
# The rebuild is released when post-ready recovery finishes, and
# recovery now resumes imports off the loop, so it can still be
# running at the first request.
for _ in range(500):
if app.state.recovery in {"done", "failed"}:
break
await asyncio.sleep(0.01)
for _ in range(500):
health = await _get_json(app, "/api/v1/health")
if health["content_candidate_backfill"] != "running":
break
await asyncio.sleep(0.01)
assert health["content_candidate_backfill"] == "failed"
assert health["content_candidate_backfill_failure"] == "database_locked"
+2 -2
View File
@@ -34,10 +34,10 @@ def test_resume_during_cleanup_returns_conflict_without_internal_error(tmp_path,
job = supervisor.job_store.create(user_id="local", source_id="fixture")
supervisor.job_store.transition(job.job_id, ImportStatus.PAUSED)
def draining(_job_id):
async def draining(_job_id):
raise InvalidJobTransitionError("import_cleanup_in_progress")
monkeypatch.setattr(supervisor, "resume", draining)
monkeypatch.setattr(supervisor, "resume_off_loop", draining)
response = await client.post(f"/api/v1/import-jobs/{job.job_id}/resume")
assert response.status_code == 409
assert response.json() == {"detail": "invalid_job_transition"}
@@ -0,0 +1,383 @@
"""`import_jobs` stays bounded, and the questions asked of it are SQL, not scans.
Live sync opened a job per change it imported and nothing ever removed one, so
the table only grew; every caller then read all of it and filtered in Python.
These tests hold the three halves of the fix: what pruning keeps, that the
filtered queries answer exactly what the old filters did, and that the indexes
behind them arrive by migration.
"""
from __future__ import annotations
import asyncio
import random
import sqlite3
from datetime import UTC, datetime, timedelta
import pytest
from httpx import ASGITransport, AsyncClient
from pydantic import SecretStr
from one_memory.api import _prune_finished_import_jobs, create_app
from one_memory.config import Settings
from one_memory.imports.jobs import (
FINISHED_STATUSES,
SYNC_JOBS_KEPT_PER_SOURCE,
ImportJob,
ImportJobStore,
ImportStatus,
)
from one_memory.memory_server.migration_runner import SqliteMigrationStore, migrate
from one_memory.memory_server.migrations import (
IMPORT_JOB_LOOKUP_INDEXES_MIGRATION_VERSION,
MIGRATIONS,
)
_BASE = datetime(2026, 1, 1, tzinfo=UTC)
def _store(tmp_path) -> ImportJobStore:
return ImportJobStore(tmp_path / "one-memory.sqlite3")
def _job(
store: ImportJobStore,
*,
user_id: str = "local",
source_id: str = "fixture",
operation: str = "sync",
status: ImportStatus = ImportStatus.COMPLETED,
minute: int,
issue: bool = False,
) -> str:
"""Insert one row as its terminal shape, at a chosen creation time."""
created = (_BASE + timedelta(minutes=minute)).isoformat()
job_id = f"{source_id}-{user_id}-{operation}-{minute:05d}"
with store._connect(writing=True) as connection:
connection.execute(
"INSERT INTO import_jobs (job_id,user_id,source_id,status,created_at,updated_at,"
"operation) VALUES (?,?,?,?,?,?,?)",
(job_id, user_id, source_id, status, created, created, operation),
)
if issue:
connection.execute(
"INSERT INTO import_issues (job_id,kind,conversation_id,reason,created_at) "
"VALUES (?,'error',NULL,'synthetic_reason',?)",
(job_id, created),
)
connection.execute(
"INSERT INTO import_skip_reason_counts (job_id,reason,count) VALUES (?,'r',1)",
(job_id,),
)
return job_id
def _ids(store: ImportJobStore, **filters) -> list[str]:
return [job.job_id for job in store.find_jobs(**filters)]
def _count(store: ImportJobStore, table: str, job_id: str) -> int:
with store._connect() as connection:
return int(
connection.execute(
f"SELECT COUNT(*) FROM {table} WHERE job_id=?", (job_id,)
).fetchone()[0]
)
def test_pruning_keeps_the_newest_finished_syncs_of_every_source(tmp_path) -> None:
store = _store(tmp_path)
keep = 5
for source in ("alpha", "beta"):
for minute in range(12):
status = ImportStatus.CANCELLED if minute % 4 == 0 else ImportStatus.COMPLETED
_job(store, source_id=source, minute=minute, status=status, issue=True)
# A source with fewer than `keep` keeps all of them.
for minute in range(3):
_job(store, source_id="gamma", minute=minute)
# Never pruned: the active sync, history of any state, another user's lane.
running = _job(store, source_id="alpha", minute=1000, status=ImportStatus.RUNNING)
failed_history = _job(
store, source_id="alpha", operation="history", minute=2, status=ImportStatus.FAILED
)
histories = [
_job(store, source_id="alpha", operation="history", minute=100 + minute)
for minute in range(10)
]
other_user = [_job(store, user_id="other", source_id="alpha", minute=m) for m in range(8)]
removed = store.prune_finished_sync_jobs(keep=keep)
assert removed == (12 - keep) * 2 + (8 - keep)
for source in ("alpha", "beta"):
finished = _ids(
store, user_id="local", source_id=source, operation="sync", statuses=FINISHED_STATUSES
)
assert finished == [f"{source}-local-sync-{m:05d}" for m in range(12 - keep, 12)]
# Their issues and skip counts went with them; the kept ones still have theirs.
assert _count(store, "import_issues", f"{source}-local-sync-00000") == 0
assert _count(store, "import_skip_reason_counts", f"{source}-local-sync-00000") == 0
assert _count(store, "import_issues", f"{source}-local-sync-00011") == 1
assert len(_ids(store, source_id="gamma")) == 3
assert store.require(running).status is ImportStatus.RUNNING
assert store.require(failed_history).status is ImportStatus.FAILED
assert all(store.get(job_id) is not None for job_id in histories)
assert _ids(store, user_id="other") == other_user[-keep:]
# The newest job of every lane, which the App and live sync health show,
# is always kept.
assert store.latest_job(user_id="local", source_id="beta").job_id == "beta-local-sync-00011"
before = _ids(store)
assert store.prune_finished_sync_jobs(keep=keep) == 0
assert _ids(store) == before
def test_pruning_is_bounded_per_call_and_finishes_when_repeated(tmp_path) -> None:
store = _store(tmp_path)
for minute in range(60):
_job(store, minute=minute)
passes = []
while True:
removed = store.prune_finished_sync_jobs(keep=10, max_rows=15)
passes.append(removed)
if removed < 15:
break
assert passes == [15, 15, 15, 5]
assert len(_ids(store)) == 10
def test_the_default_keep_count_is_twenty(tmp_path) -> None:
store = _store(tmp_path)
for minute in range(SYNC_JOBS_KEPT_PER_SOURCE + 7):
_job(store, minute=minute)
assert store.prune_finished_sync_jobs() == 7
assert len(_ids(store)) == SYNC_JOBS_KEPT_PER_SOURCE == 20
def test_startup_pruning_runs_to_completion_off_the_loop(tmp_path) -> None:
store = _store(tmp_path)
for minute in range(500):
_job(store, minute=minute)
async def scenario() -> None:
task = asyncio.create_task(_prune_finished_import_jobs(store, interval_seconds=3600))
for _ in range(500):
await asyncio.sleep(0.01)
if len(await asyncio.to_thread(_ids, store)) == SYNC_JOBS_KEPT_PER_SOURCE:
break
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
asyncio.run(scenario())
assert len(_ids(store)) == SYNC_JOBS_KEPT_PER_SOURCE
def test_filtered_queries_answer_what_the_python_filters_did(tmp_path) -> None:
store = _store(tmp_path)
rng = random.Random(7)
lanes = [(user, source) for user in ("local", "other") for source in ("a", "b", "c")]
for minute in range(240):
user, source = rng.choice(lanes)
_job(
store,
user_id=user,
source_id=source,
operation=rng.choice(("sync", "history")),
# Only terminal shapes: the active-slot index allows one active job per lane.
status=rng.choice(FINISHED_STATUSES),
minute=minute,
)
everything: list[ImportJob] = store.list_jobs()
assert len(everything) == 240
status_sets = [
None,
(ImportStatus.COMPLETED,),
(ImportStatus.CANCELLED, ImportStatus.COMPLETED),
(ImportStatus.RUNNING,),
]
for user_id in (None, "local", "other"):
for source_id in (None, "a", "c"):
for operation in (None, "sync", "history"):
for statuses in status_sets:
expected = [
job
for job in everything
if (user_id is None or job.user_id == user_id)
and (source_id is None or job.source_id == source_id)
and (operation is None or job.operation == operation)
and (statuses is None or job.status in statuses)
]
found = store.find_jobs(
user_id=user_id,
source_id=source_id,
operation=operation,
statuses=statuses,
)
assert found == expected
newest = store.find_jobs(
user_id=user_id,
source_id=source_id,
operation=operation,
statuses=statuses,
newest_first=True,
limit=1,
)
assert newest == (
[max(expected, key=lambda job: (job.created_at, job.job_id))]
if expected
else []
)
for status in ImportStatus:
assert store.list_jobs(status=status) == [job for job in everything if job.status is status]
assert store.find_jobs(statuses=()) == []
def test_the_lookup_indexes_arrive_by_migration_and_serve_the_lane_query(tmp_path) -> None:
path = tmp_path / "one-memory.sqlite3"
older = tuple(
item for item in MIGRATIONS if item.version < IMPORT_JOB_LOOKUP_INDEXES_MIGRATION_VERSION
)
asyncio.run(migrate(SqliteMigrationStore(path), older))
with sqlite3.connect(path) as database:
before = {
row[0] for row in database.execute("SELECT name FROM sqlite_master WHERE type='index'")
}
assert "import_jobs_lane" not in before
result = asyncio.run(migrate(SqliteMigrationStore(path), MIGRATIONS))
assert result.applied == (IMPORT_JOB_LOOKUP_INDEXES_MIGRATION_VERSION,)
with sqlite3.connect(path) as database:
indexes = {
row[0] for row in database.execute("SELECT name FROM sqlite_master WHERE type='index'")
}
plan = " ".join(
str(row[-1])
for row in database.execute(
"EXPLAIN QUERY PLAN SELECT * FROM import_jobs "
"WHERE user_id=? AND source_id=? AND operation=? AND status IN (?,?) "
"ORDER BY created_at DESC LIMIT 1",
("local", "fixture", "sync", "pending", "running"),
)
)
assert {"import_jobs_lane", "import_jobs_status"} <= indexes
assert "import_jobs_lane" in plan
# Re-running it is a no-op, and a store opened on the database agrees.
asyncio.run(migrate(SqliteMigrationStore(path), MIGRATIONS))
ImportJobStore(path)
def test_a_running_home_gets_the_indexes_from_the_store(tmp_path) -> None:
path = tmp_path / "one-memory.sqlite3"
ImportJobStore(path)
with sqlite3.connect(path) as database:
database.execute("DROP INDEX import_jobs_lane")
database.execute("DROP INDEX import_jobs_status")
ImportJobStore(path)
with sqlite3.connect(path) as database:
indexes = {
row[0] for row in database.execute("SELECT name FROM sqlite_master WHERE type='index'")
}
assert {"import_jobs_lane", "import_jobs_status"} <= indexes
def test_the_mysql_rendering_tolerates_an_index_already_there() -> None:
class Cursor:
dialect = "mysql"
def __init__(self) -> None:
self.attempted: list[str] = []
async def execute(self, statement: str) -> None:
self.attempted.append(statement)
raise RuntimeError(1061, "duplicate key name")
cursor = Cursor()
migration = next(
item for item in MIGRATIONS if item.version == IMPORT_JOB_LOOKUP_INDEXES_MIGRATION_VERSION
)
asyncio.run(migration.apply(cursor))
assert cursor.attempted and "import_jobs" in cursor.attempted[0]
def test_the_import_jobs_route_keeps_its_shape_and_order(tmp_path) -> None:
async def scenario() -> None:
app = create_app(
Settings(
data_dir=tmp_path / "data",
source_home=tmp_path / "home",
api_token=SecretStr("synthetic"),
automatic_memory_processing=False,
live_sync_enabled=False,
)
)
async with (
app.router.lifespan_context(app),
AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
headers={"Authorization": "Bearer synthetic"},
) as client,
):
store: ImportJobStore = app.state.import_job_store
for minute in (3, 1, 2):
_job(store, source_id="fixture", minute=minute)
_job(store, source_id="fixture", operation="history", minute=0)
_job(store, user_id="other", source_id="hermes", minute=4)
response = await client.get("/api/v1/import-jobs")
other = await client.get("/api/v1/import-jobs", params={"user_id": "other"})
assert response.status_code == 200
body = response.json()
expected = [job for job in store.list_jobs() if job.user_id == "local"]
assert [item["job_id"] for item in body] == [job.job_id for job in expected]
assert [item["created_at"] for item in body] == sorted(
item["created_at"] for item in body
)
assert set(body[0]) == set(ImportJob.model_fields)
assert [item["user_id"] for item in other.json()] == ["other"]
asyncio.run(scenario())
def test_memory_processing_reports_the_write_drain(tmp_path) -> None:
async def scenario() -> None:
app = create_app(
Settings(
data_dir=tmp_path / "data",
source_home=tmp_path / "home",
api_token=SecretStr("synthetic"),
automatic_memory_processing=False,
live_sync_enabled=False,
)
)
async with (
app.router.lifespan_context(app),
AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
headers={"Authorization": "Bearer synthetic"},
) as client,
):
for _ in range(500):
if app.state.recovery in {"done", "failed"}:
break
await asyncio.sleep(0.01)
body = (await client.get("/api/v1/memory-processing")).json()
assert body["write_drain"] == "running"
assert body["write_drain_error"] is None
drain = app.state.memory_write_drain
drain.liveness = "restarting"
drain.last_error_code = "operational_error"
body = (await client.get("/api/v1/memory-processing")).json()
assert body["write_drain"] == "restarting"
assert body["write_drain_error"] == "operational_error"
drain.liveness = "running"
asyncio.run(scenario())
@@ -415,15 +415,15 @@ def test_shutdown_preserves_a_job_that_finished_after_the_running_snapshot(
task = asyncio.create_task(waiting())
supervisor._tasks[job.job_id] = task
await asyncio.sleep(0)
original = jobs.list_jobs
original = jobs.find_jobs
def finish_after_snapshot(*args, **kwargs):
found = original(*args, **kwargs)
if kwargs.get("status") is ImportStatus.RUNNING:
if tuple(kwargs.get("statuses") or ()) == (ImportStatus.RUNNING,):
jobs.finish_owned(job.job_id, runner_token=token, target=terminal)
return found
monkeypatch.setattr(jobs, "list_jobs", finish_after_snapshot)
monkeypatch.setattr(jobs, "find_jobs", finish_after_snapshot)
try:
await supervisor.shutdown()
assert jobs.require(job.job_id).status is terminal
@@ -433,3 +433,45 @@ def test_shutdown_preserves_a_job_that_finished_after_the_running_snapshot(
await asyncio.gather(task, return_exceptions=True)
asyncio.run(scenario())
def test_abandon_makes_its_database_calls_off_the_event_loop(tmp_path, monkeypatch) -> None:
"""Live sync abandons from its poll; a writer wait there froze the whole Core."""
import threading
async def scenario() -> None:
evidence, jobs = stores(tmp_path)
supervisor = ImportSupervisor(adapters={}, evidence_store=evidence, job_store=jobs)
job = jobs.create(user_id="local", source_id="fixture", operation="sync")
job, _token = jobs.claim(job.job_id)
loop_thread = threading.current_thread()
calls: list[tuple[str, threading.Thread]] = []
for name in ("require", "interrupt_running"):
original = getattr(jobs, name)
def recorded(*args, _name=name, _original=original, **kwargs):
calls.append((_name, threading.current_thread()))
return _original(*args, **kwargs)
monkeypatch.setattr(jobs, name, recorded)
async def wedged() -> None:
# Ignores the first cancel, like a runner stuck in a thread.
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
await asyncio.sleep(0.2)
task = asyncio.create_task(wedged())
supervisor._tasks[job.job_id] = task
await asyncio.sleep(0)
try:
assert await supervisor.abandon(job.job_id, timeout=0.01) is False
finally:
await asyncio.gather(task, return_exceptions=True)
assert {name for name, _thread in calls} == {"require", "interrupt_running"}
assert all(thread is not loop_thread for _name, thread in calls)
assert jobs.require(job.job_id).status is ImportStatus.INTERRUPTED
asyncio.run(scenario())
+29
View File
@@ -641,3 +641,32 @@ def _job(*, created_at: datetime) -> ImportJob:
created_at=created_at,
updated_at=datetime.now(UTC),
)
def test_attributing_changed_paths_happens_off_the_event_loop(tmp_path: Path, monkeypatch) -> None:
"""Resolving a host's batch of changed paths blocked the loop for seconds."""
import threading
async def scenario() -> None:
root, (_evidence, live, imports) = _source(tmp_path)
watch = FileWatchLiveSync(
roots={PI_DESCRIPTOR.source_id: root},
supervisor=live,
debounce_seconds=0.1,
min_interval_seconds=0.1,
)
loop_thread = threading.current_thread()
resolved_on: list[threading.Thread] = []
original = watch.source_for
def recorded(path: Path) -> str | None:
resolved_on.append(threading.current_thread())
return original(path)
monkeypatch.setattr(watch, "source_for", recorded)
assert await watch.handle_changes({tmp_path / "elsewhere/session.jsonl"}) == ()
assert resolved_on and loop_thread not in resolved_on
await imports.shutdown()
asyncio.run(scenario())
@@ -318,9 +318,31 @@ def test_main_database_checkpoints_on_a_write_cadence(tmp_path, monkeypatch):
with database.write() as db, db:
db.execute("UPDATE counter SET value = 2 WHERE id = 1")
assert _access.wait_for_cadence_checkpoints()
assert checkpoints and checkpoints[0][0] == "PASSIVE"
def test_the_cadence_checkpoint_does_not_run_on_the_writing_thread(tmp_path, monkeypatch):
"""The thread that wrote returns at once; the checkpointer takes the next turn."""
path = _database(tmp_path)
database = main_database(path)
ran_on = []
original = database._checkpoint_from_maintenance
def observed(mode):
ran_on.append(threading.current_thread().name)
return original(mode)
monkeypatch.setattr(database, "_checkpoint_from_maintenance", observed)
monkeypatch.setattr(_access, "MAIN_DATABASE_CHECKPOINT_WRITE_INTERVAL", 1)
with database.write() as db, db:
db.execute("UPDATE counter SET value = 1 WHERE id = 1")
assert _access.wait_for_cadence_checkpoints()
assert ran_on == ["one-memory-checkpoint"]
def test_main_database_warns_when_wal_stays_above_the_high_water_mark(
tmp_path, monkeypatch, caplog
):
@@ -332,6 +354,7 @@ def test_main_database_warns_when_wal_stays_above_the_high_water_mark(
with database.write() as db, db:
db.execute("UPDATE counter SET value = 1 WHERE id = 1")
assert _access.wait_for_cadence_checkpoints()
assert any("main_database_wal_high" in record.message for record in caplog.records)
@@ -349,6 +372,7 @@ def test_a_wal_whose_frames_are_all_checkpointed_is_not_reported_high(
with database.write() as db, db:
db.execute("UPDATE counter SET value = 1 WHERE id = 1")
assert _access.wait_for_cadence_checkpoints()
assert database._wal_bytes() > 0
assert not any("main_database_wal_high" in record.message for record in caplog.records)
@@ -4138,6 +4138,71 @@ def test_a_failing_renewal_does_not_kill_the_drain(tmp_path) -> None:
assert queue.get("idem-1").state == COMPLETED
def test_a_locked_fallback_settlement_does_not_kill_the_drain(tmp_path, monkeypatch) -> None:
"""The incident shape: executor and its fallback both lose the writer.
The first row keeps its lease for normal reclaim; an unrelated row must
still run instead of being stranded behind a dead drain task.
"""
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
first = queue.submit(_request(key="first", source="cursor"))
second = queue.submit(_request(key="second", source="codex"))
executed: list[str] = []
async def execute(job_id: str) -> None:
executed.append(job_id)
if job_id == first.job_id:
raise RuntimeError("executor failed")
queue.settle(job_id, state=COMPLETED)
drain.stop()
drain = DurableWriteDrain(queue, execute=execute, max_concurrent_sources=1)
def locked_settlement(*_args, **_kwargs):
raise sqlite3.OperationalError("database is locked")
monkeypatch.setattr(queue, "settle_worker_error", locked_settlement)
asyncio.run(asyncio.wait_for(drain.run_forever(idle_pause=0.005), 1))
assert executed == [first.job_id, second.job_id]
assert queue.get("first").state == RUNNING
assert queue.get("second").state == COMPLETED
def test_an_unexpected_job_task_failure_is_contained_by_the_drain(
tmp_path, monkeypatch, caplog
) -> None:
"""A programming error stays visible without taking down the consumer."""
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
first = queue.submit(_request(key="first", source="cursor"))
second = queue.submit(_request(key="second", source="codex"))
executed: list[str] = []
async def run_job(job_id: str) -> None:
executed.append(job_id)
if job_id == first.job_id:
raise RuntimeError("unexpected job task failure")
drain.stop()
drain = DurableWriteDrain(
queue,
execute=_never_execute,
max_concurrent_sources=1,
)
monkeypatch.setattr(drain, "_run_claimed_job", run_job)
with caplog.at_level(logging.ERROR, logger="one_memory.memory_server.durable_queue"):
asyncio.run(asyncio.wait_for(drain.run_forever(idle_pause=0.005), 1))
assert executed == [first.job_id, second.job_id]
assert any(
"durable_write_drain_job_task_failed" in message.getMessage()
and "error_type=RuntimeError" in message.getMessage()
for message in caplog.records
)
@pytest.mark.parametrize("terminal", [COMPLETED, FAILED, DEAD])
def test_a_terminal_row_is_sticky_against_a_late_settle(tmp_path, terminal: str) -> None:
"""A late settle must not overwrite an outcome that already happened.
@@ -4980,6 +5045,27 @@ def test_permanent_renewal_error_stops_the_worker(tmp_path, monkeypatch) -> None
asyncio.run(drain.run_one())
def test_permanent_renewal_error_stops_the_long_lived_drain(tmp_path, monkeypatch) -> None:
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
queue.submit(_request())
drain = DurableWriteDrain(
queue,
execute=lambda _job_id: asyncio.sleep(0.03),
lease=timedelta(seconds=1),
renew_every=timedelta(milliseconds=10),
)
monkeypatch.setattr(
drain,
"renew",
lambda *_args, **_kwargs: (_ for _ in ()).throw(
sqlite3.OperationalError("database schema is corrupt")
),
)
with pytest.raises(RuntimeError):
asyncio.run(asyncio.wait_for(drain.run_forever(idle_pause=0.005), 1))
def test_lost_renewal_waits_for_execution_before_releasing_source(tmp_path, monkeypatch) -> None:
path = tmp_path / "one-memory.sqlite3"
queue = DurableWriteQueue(path)
@@ -5442,3 +5528,190 @@ def test_a_disabled_source_suspends_its_jobs_and_enabling_it_resumes_them(tmp_pa
assert runtime.status_counts() == {PENDING: 3, COMPLETED: 1}
assert runtime.queue_depths().fresh == 3
assert len(drain.claim(limit=10)) == 1
def test_a_claim_path_error_restarts_the_drain_and_later_jobs_still_run(
tmp_path, monkeypatch
) -> None:
"""An unexpected SQLite error on the claim path used to end the drain for good."""
from one_memory.memory_server import durable_queue as _queue_module
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
queue.submit(_request(key="later", source="cursor"))
reported: list[tuple[str, BaseException]] = []
monkeypatch.setattr(
_queue_module,
"report_exception",
lambda component, event, error, **_kwargs: reported.append((event, error)),
)
async def execute(job_id: str) -> None:
queue.settle(job_id, state=COMPLETED)
drain.stop()
drain = DurableWriteDrain(queue, execute=execute, max_concurrent_sources=1)
real_claim = drain.claim
calls = {"claim": 0}
def failing_once(**kwargs):
calls["claim"] += 1
if calls["claim"] == 1:
raise sqlite3.OperationalError("disk I/O error")
return real_claim(**kwargs)
monkeypatch.setattr(drain, "claim", failing_once)
first_owner = drain.owner
asyncio.run(
asyncio.wait_for(
drain.run_supervised(idle_pause=0.005, restart_backoff=0.01, max_restart_backoff=0.05),
2,
)
)
assert queue.get("later").state == COMPLETED
assert drain.restarts == 1
assert drain.last_error_code == "operational_error"
assert drain.liveness == "stopped"
# A restarted drain leases under a new name: nothing the old one left in a
# pool thread can renew or settle as it.
assert drain.owner != first_owner
assert [event for event, _error in reported] == ["memory.write_queue.drain_exited"]
assert reported[0][1].__traceback__ is not None
def test_a_claim_gate_exception_restarts_the_drain(tmp_path, monkeypatch) -> None:
from one_memory.memory_server import durable_queue as _queue_module
monkeypatch.setattr(_queue_module, "report_exception", lambda *_args, **_kwargs: None)
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
queue.submit(_request(key="gated", source="cursor"))
asked = {"count": 0}
def gate(_source_key: str) -> bool:
asked["count"] += 1
if asked["count"] == 1:
raise KeyError("gate bug")
return True
async def execute(job_id: str) -> None:
queue.settle(job_id, state=COMPLETED)
drain.stop()
drain = DurableWriteDrain(queue, execute=execute, source_claim_enabled=gate)
asyncio.run(asyncio.wait_for(drain.run_supervised(idle_pause=0.005, restart_backoff=0.01), 2))
assert queue.get("gated").state == COMPLETED
assert drain.last_error_code == "key_error"
def test_stop_ends_a_restart_backoff_at_once(tmp_path, monkeypatch) -> None:
from one_memory.memory_server import durable_queue as _queue_module
monkeypatch.setattr(_queue_module, "report_exception", lambda *_args, **_kwargs: None)
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
drain = DurableWriteDrain(queue, execute=_never_execute)
def broken(**_kwargs):
raise sqlite3.DatabaseError("database disk image is malformed")
monkeypatch.setattr(drain, "claim", broken)
async def scenario() -> None:
task = asyncio.create_task(drain.run_supervised(restart_backoff=30))
for _ in range(200):
if drain.liveness == "restarting":
break
await asyncio.sleep(0.005)
assert drain.liveness == "restarting"
drain.stop()
await asyncio.wait_for(task, 1)
asyncio.run(scenario())
assert drain.liveness == "stopped"
assert drain.last_error_code == "database_error"
def test_a_cancelled_supervised_drain_stays_cancelled(tmp_path) -> None:
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
drain = DurableWriteDrain(queue, execute=_never_execute)
async def scenario() -> None:
task = asyncio.create_task(drain.run_supervised(idle_pause=0.005))
await asyncio.sleep(0.05)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
asyncio.run(scenario())
assert drain.liveness == "stopped"
assert drain.restarts == 0
def test_a_contained_job_task_failure_is_reported_with_its_stack(tmp_path, monkeypatch) -> None:
from one_memory.memory_server import durable_queue as _queue_module
reported: list[tuple[str, BaseException]] = []
monkeypatch.setattr(
_queue_module,
"report_exception",
lambda component, event, error, **_kwargs: reported.append((event, error)),
)
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
first = queue.submit(_request(key="first", source="cursor"))
queue.submit(_request(key="second", source="codex"))
async def run_job(job_id: str) -> None:
if job_id == first.job_id:
raise RuntimeError("systematic job task bug")
drain.stop()
drain = DurableWriteDrain(queue, execute=_never_execute, max_concurrent_sources=1)
monkeypatch.setattr(drain, "_run_claimed_job", run_job)
asyncio.run(asyncio.wait_for(drain.run_forever(idle_pause=0.005), 1))
assert [event for event, _error in reported] == ["memory.write_queue.job_task_failed"]
assert reported[0][1].__traceback__ is not None
def test_a_lease_is_renewed_while_every_bulk_thread_is_blocked(tmp_path) -> None:
"""The incident: bulk threads stuck in job listings starved lease renewal."""
from one_memory.background_work import BULK_WORKERS, bulk_pool, shutdown_bulk_pool
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
queue.submit(_request(key="slow", source="cursor"))
release = threading.Event()
blocked = [bulk_pool().submit(release.wait, 10) for _ in range(BULK_WORKERS * 2)]
renewed: list[bool] = []
async def execute(job_id: str) -> None:
# Longer than the lease: only renewals keep it.
await asyncio.sleep(0.8)
queue.settle(job_id, state=COMPLETED, owner=drain.owner)
drain = DurableWriteDrain(
queue,
execute=execute,
lease=timedelta(milliseconds=400),
renew_every=timedelta(milliseconds=50),
)
real_renew = drain.renew
def counted(job_id: str, **kwargs) -> bool:
result = real_renew(job_id, **kwargs)
renewed.append(result)
return result
drain.renew = counted # type: ignore[method-assign]
try:
assert asyncio.run(asyncio.wait_for(drain.run_one(), 3)) == 1
finally:
release.set()
for future in blocked:
future.result(timeout=5)
shutdown_bulk_pool()
assert renewed and all(renewed)
assert queue.get("slow").state == COMPLETED
@@ -0,0 +1,237 @@
"""MemHub's raw search against the hy-memory wheel it pins, not the repo source.
The suite's pythonpath puts the repository root first, so `import hy_memory`
in a test reads the repo's `hy_memory/`, which can be ahead of the pinned
release. Search kwargs the release does not accept then pass every test and
fail every raw search in a packaged build (a `statuses` argument did exactly
that against 2.0.13). These tests run in a child interpreter started with
`-I`, so `hy_memory` resolves to the wheel installed in the Core venv, and
drive the real routes over a real lite SDK client with a deterministic local
embedder (no model, no network, no ONNX assets).
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import textwrap
import pytest
from tests.test_sdk_api_surface import SDK_SEARCH_PARAMETERS
pytestmark = pytest.mark.integration
_CHILD = textwrap.dedent(
r'''
import asyncio
import hashlib
import inspect
import json
import math
import re
import sys
from importlib.metadata import version
from pathlib import Path
import hy_memory
from hy_memory import HyMemoryClient
from one_memory.hy_runtime_config import HY_MEMORY_VERSION
home = Path(sys.argv[1])
wheel = Path(hy_memory.__file__).resolve()
assert "site-packages" in wheel.parts, f"not the installed wheel: {wheel}"
assert version("hy-memory") == HY_MEMORY_VERSION, version("hy-memory")
parameters = sorted(
name
for name, parameter in inspect.signature(HyMemoryClient.search).parameters.items()
if name not in {"self", "query"} and parameter.kind is not inspect.Parameter.VAR_KEYWORD
)
var_keyword = any(
parameter.kind is inspect.Parameter.VAR_KEYWORD
for parameter in inspect.signature(HyMemoryClient.search).parameters.values()
)
from httpx import ASGITransport, AsyncClient
from pydantic import SecretStr
import one_memory.api as api
from one_memory.api import create_app
from one_memory.config import Settings
from one_memory.embedding_assets import BUILTIN_EMBEDDING_DIMENSIONS
from one_memory.hy_sdk_runtime import (
LocalOnnxEmbeddingService,
SdkClientHandle,
builtin_retrieval_snapshot,
)
from one_memory.secrets import InMemorySecretStore
WORD = re.compile(r"\w+")
class HashedEmbedder:
"""Bag of words and CJK bigrams, hashed: texts sharing words are near."""
dimensions = BUILTIN_EMBEDDING_DIMENSIONS
def encode(self, texts):
vectors = []
for text in texts:
vector = [0.0] * self.dimensions
for word in WORD.findall(text.casefold()):
grams = [word]
if not word.isascii():
grams += [word[i : i + 2] for i in range(len(word) - 1)]
for gram in grams:
digest = hashlib.sha256(gram.encode()).digest()
vector[int.from_bytes(digest[:4], "big") % self.dimensions] += 1.0
norm = math.sqrt(sum(value * value for value in vector)) or 1.0
vectors.append([value / norm for value in vector])
return vectors
LocalOnnxEmbeddingService._embedder = HashedEmbedder()
async def lite_has_no_model(_app):
return False
api._recall_model_ready = lite_has_no_model
async def main():
settings = Settings(
data_dir=home / "data",
source_home=home,
api_token=SecretStr("pinned-sdk"),
imports_enabled=False,
automatic_memory_processing=False,
live_sync_enabled=False,
)
app = create_app(settings, secret_store=InMemorySecretStore())
app.state.memory_mode = lambda: "lite"
handle = SdkClientHandle(
builtin_retrieval_snapshot(),
api_key=None,
data_dir=home / "data",
mode="lite",
)
result = {"parameters": parameters, "var_keyword": var_keyword}
async with app.router.lifespan_context(app):
app.state.sdk_client_handle = handle
client = await handle.client()
for session, messages in (
(
"s-hike",
[
("user", "我叫小林"),
("assistant", "你好小林"),
("user", "I love hiking mountain trails at dawn"),
("assistant", "Noted, early hikes."),
],
),
(
"s-tea",
[
("user", "my favourite drink is green tea"),
("assistant", "Green tea, noted."),
],
),
):
written = client.add(
[{"role": role, "content": content} for role, content in messages],
user_id="local",
source="hermes",
session_id=session,
)
assert written.get("success", True), written
listed = client.list_memories(user_id="local", layers=["l1_raw"], limit=10)
rows = listed.get("vdb", listed)["memories"]
tea = next(row for row in rows if "green tea" in row["content"])
# What pro leaves behind: the raw it already processed is shadow.
client._loop_thread.run(
client._vector_store.update_payload(tea["memory_id"], {"status": "shadow"})
)
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
headers={"Authorization": "Bearer pinned-sdk"},
) as http:
for name, body in (
("hike_turn", {"query": "hiking trails mountain", "raw_unit": "turn"}),
("tea_turn", {"query": "favourite green tea drink", "raw_unit": "turn"}),
("tea_raw", {"query": "favourite green tea drink"}),
):
response = await http.post(
"/api/v2/context/search", json={"kinds": ["raw"], "limit": 5, **body}
)
result[name] = {"status": response.status_code, "body": response.json()}
response = await http.post(
"/api/v1/recall", json={"query": "favourite green tea drink"}
)
result["recall"] = {"status": response.status_code, "body": response.json()}
await handle.close()
print("RESULT " + json.dumps(result, ensure_ascii=False))
asyncio.run(main())
'''
)
@pytest.fixture(scope="module")
def pinned(tmp_path_factory) -> dict:
root = tmp_path_factory.mktemp("pinned-sdk")
script = root / "scripts" / "child.py"
script.parent.mkdir()
script.write_text(_CHILD, encoding="utf-8")
home = root / "home"
home.mkdir()
environment = {
**os.environ,
"ANONYMIZED_TELEMETRY": "False",
"ONE_MEMORY_DATA_DIR": str(home / "data"),
}
completed = subprocess.run(
[sys.executable, "-I", str(script), str(home)],
cwd=home,
env=environment,
capture_output=True,
text=True,
timeout=240,
check=False,
)
assert completed.returncode == 0, completed.stderr[-4000:]
[line] = [line for line in completed.stdout.splitlines() if line.startswith("RESULT ")]
return json.loads(line.removeprefix("RESULT "))
def test_the_fake_sdk_accepts_exactly_the_pinned_search_parameters(pinned) -> None:
# The route tests' stand-in rejects what the pinned SDK rejects.
assert pinned["var_keyword"] is False
assert sorted(SDK_SEARCH_PARAMETERS) == pinned["parameters"]
def test_lite_turn_search_returns_semantic_hits_without_the_literal_query(pinned) -> None:
hike = pinned["hike_turn"]
assert hike["status"] == 200, hike
items = hike["body"]["items"]
assert items, hike["body"]
top = items[0]
# No turn contains "hiking trails mountain"; the hit still comes back,
# shown by its closest turn and carrying the hit's score.
assert "hiking mountain trails" in top["content"]
assert "小林" not in top["content"]
assert top["turn_index"] == 1 and top["turn_count"] == 2
assert [item["score"] for item in items] == sorted(
(item["score"] for item in items), reverse=True
)
def test_lite_search_and_recall_include_shadow_raws(pinned) -> None:
for name in ("tea_turn", "tea_raw"):
result = pinned[name]
assert result["status"] == 200, result
assert any("green tea" in item["content"] for item in result["body"]["items"]), result
recall = pinned["recall"]
assert recall["status"] == 200, recall
assert recall["body"]["mode"] == "raw"
assert any("green tea" in hit["content"] for hit in recall["body"]["hits"]), recall
@@ -189,7 +189,11 @@ def test_first_import_gate_waits_for_every_selected_history_job() -> None:
app = SimpleNamespace(
state=SimpleNamespace(
onboarding_store=SimpleNamespace(get=lambda: state),
import_job_store=SimpleNamespace(list_jobs=lambda: jobs),
import_job_store=SimpleNamespace(
find_jobs=lambda *, user_id, operation: [
job for job in jobs if job.user_id == user_id and job.operation == operation
]
),
)
)
+169 -2
View File
@@ -42,7 +42,10 @@ RAW_ROWS = {
"local": [
{
"memory_id": "raw-1",
"content": "[user]: 我叫费天翔\n[assistant]: 好的,记住了。",
"content": (
"[user]: 我叫费天翔\n[assistant]: 好的,记住了。"
"\n[user]: 我喜欢绿茶\n[assistant]: 也记住了。"
),
"status": "shadow",
"session_id": "s-1",
"source": "hermes",
@@ -88,6 +91,40 @@ def _chat(user_id: str, memory_id: str, content: str, layer: str, source: str) -
}
#: The keyword parameters of the pinned `HyMemoryClient.search`, which takes
#: no **kwargs. `test_pinned_sdk_raw_search` checks this set against the
#: installed wheel, so the stand-in below rejects what the release rejects.
SDK_SEARCH_PARAMETERS = frozenset(
{
"queries",
"scene",
"search_mode",
"user_ids",
"agent_ids",
"session_ids",
"include_sources",
"exclude_sources",
"limit",
"min_score",
"working_fact_min_score",
"working_experience_min_score",
"schema_min_score",
"schema_limit",
"intention_limit",
"created_after",
"reader",
"workspace_id",
"branch",
"request_id",
"layers",
"statuses",
"embed_override",
"query_embedding",
"rerank",
}
)
class RecordingSdk:
"""The public client surface, recording every call's keyword arguments."""
@@ -106,6 +143,10 @@ class RecordingSdk:
# The SDK answers rows below a caller's floor in some lanes (a schema
# floor, a fused rank score), so Core must filter what it returns.
def search(self, query, **kwargs):
unknown = set(kwargs) - SDK_SEARCH_PARAMETERS
if unknown:
# The pinned `HyMemoryClient.search` takes no **kwargs.
raise TypeError(f"search() got unexpected keyword arguments {sorted(unknown)}")
self._record("search", {"query": query, **kwargs})
[user_id] = kwargs["user_ids"]
if kwargs.get("layers") == ["l1_raw"]:
@@ -271,6 +312,10 @@ async def _model_ready(_app) -> bool:
return True
async def _model_not_ready(_app) -> bool:
return False
def run_with_sdk(tmp_path, monkeypatch, scenario) -> None:
async def main() -> None:
app = application(tmp_path)
@@ -347,6 +392,75 @@ def test_every_context_search_option_reaches_the_sdk_and_absent_ones_keep_the_ol
run_with_sdk(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_raw_context_search_can_return_turns_instead_of_storage_batches(
tmp_path, monkeypatch
) -> None:
async def scenario(app, sdk, client):
response = await client.post(
"/api/v2/context/search",
json={
"query": "绿茶",
"kinds": ["raw"],
"raw_unit": "turn",
"limit": 20,
},
)
assert response.status_code == 200, response.text
raw_one = [item for item in response.json()["items"] if item["raw_id"] == "raw-1"]
assert len(raw_one) == 1
assert raw_one[0]["turn_index"] == 1
assert "我喜欢绿茶" in raw_one[0]["content"]
assert "我叫费天翔" not in raw_one[0]["content"]
# pro/ultra: a shadow raw is already in its extracted memories.
assert "statuses" not in sdk.last("search")
run_with_sdk(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_lite_raw_search_is_plain_rag_over_active_and_shadow_raws(tmp_path, monkeypatch) -> None:
async def scenario(app, sdk, client):
app.state.memory_mode = lambda: "lite"
response = await client.post(
"/api/v2/context/search",
json={"query": "喜欢喝绿茶的人", "kinds": ["raw"], "raw_unit": "turn", "limit": 5},
)
assert response.status_code == 200, response.text
sent = sdk.last("search")
assert sent["statuses"] == ["active", "shadow"] and sent["limit"] == 5
items = response.json()["items"]
# No turn contains the query, yet every vector hit is returned, in
# score order: raw-1 by its closest turn, raw-2 by its only one.
assert [(item["raw_id"], item["turn_index"]) for item in items] == [
("raw-1", 1),
("raw-2", 0),
]
assert [item["score"] for item in items] == [0.97, 0.3]
assert "我喜欢绿茶" in items[0]["content"]
floored = await client.post(
"/api/v2/context/search",
json={
"query": "喜欢喝绿茶的人",
"kinds": ["raw"],
"raw_unit": "turn",
"min_score": 0.5,
},
)
assert [item["raw_id"] for item in floored.json()["items"]] == ["raw-1"]
# Lite has no model, so recall answers from the raw layer.
monkeypatch.setattr("one_memory.api._recall_model_ready", _model_not_ready)
recall = await client.post("/api/v1/recall", json={"query": "喜欢喝绿茶的人"})
assert recall.status_code == 200, recall.text
assert recall.json()["mode"] == "raw"
assert sdk.last("search")["statuses"] == ["active", "shadow"]
assert [hit["memory_id"] for hit in recall.json()["hits"]][:1] == ["raw-1"]
run_with_sdk(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_every_recall_and_ask_option_reaches_the_sdk(tmp_path, monkeypatch) -> None:
async def scenario(app, sdk, client):
@@ -574,7 +688,12 @@ def test_raw_lists_active_and_shadow_turns_with_roles_and_hashes(tmp_path, monke
assert sent["layers"] == ["l1_raw"] and sent["statuses"] == ["active", "shadow"]
first = next(item for item in body["items"] if item["raw_id"] == "raw-1")
assert first["status"] == "shadow"
assert [message["role"] for message in first["messages"]] == ["user", "assistant"]
assert [message["role"] for message in first["messages"]] == [
"user",
"assistant",
"user",
"assistant",
]
assert first["messages"][0]["content"] == "我叫费天翔"
assert first["role"] is None
expected = hashlib.sha256(first["content"].encode()).hexdigest()
@@ -584,6 +703,54 @@ def test_raw_lists_active_and_shadow_turns_with_roles_and_hashes(tmp_path, monke
second = next(item for item in body["items"] if item["raw_id"] == "raw-2")
assert second["role"] == "user"
turns = (await client.get("/api/v2/raw", params={"unit": "turn"})).json()
assert turns["unit"] == "turn" and turns["total"] == 3
first_turns = [item for item in turns["items"] if item["raw_id"] == "raw-1"]
assert [item["turn_index"] for item in first_turns] == [1, 0]
assert len({item["turn_id"] for item in first_turns}) == 2
assert [[message["content"] for message in item["messages"]] for item in first_turns] == [
["我喜欢绿茶", "也记住了。"],
["我叫费天翔", "好的,记住了。"],
]
calls_before_refresh = len(sdk.all("list_memories"))
await client.get("/api/v2/raw", params={"unit": "turn"})
assert len(sdk.all("list_memories")) == calls_before_refresh + 1
# A live session puts a new raw on top: one listing call, not a rescan.
newest = {
**RAW_ROWS["local"][1],
"memory_id": "raw-new",
"content": "[user]: 刚刚说的",
"status": "active",
}
original = RAW_ROWS["local"]
monkeypatch.setitem(RAW_ROWS, "local", [newest, *original])
calls_before_append = len(sdk.all("list_memories"))
appended = (await client.get("/api/v2/raw", params={"unit": "turn"})).json()
assert len(sdk.all("list_memories")) == calls_before_append + 1
assert appended["total"] == 4
assert [item["raw_id"] for item in appended["items"]][:1] == ["raw-new"]
assert [item["turn_id"] for item in appended["items"][1:]] == [
item["turn_id"] for item in turns["items"]
]
# Anything else (here a raw going away) lists the raw layer again.
monkeypatch.setitem(RAW_ROWS, "local", [newest, original[1]])
calls_before_delete = len(sdk.all("list_memories"))
shrunk = (await client.get("/api/v2/raw", params={"unit": "turn"})).json()
assert len(sdk.all("list_memories")) > calls_before_delete + 1
assert shrunk["total"] == 2
monkeypatch.setitem(RAW_ROWS, "local", original)
calls_before_restore = len(sdk.all("list_memories"))
restored = (await client.get("/api/v2/raw", params={"unit": "turn"})).json()
assert len(sdk.all("list_memories")) > calls_before_restore + 1
assert restored["total"] == 3
ascending = (
await client.get("/api/v2/raw", params={"unit": "turn", "order": "asc"})
).json()
ascending_first = [item for item in ascending["items"] if item["raw_id"] == "raw-1"]
assert [item["turn_index"] for item in ascending_first] == [0, 1]
await client.get(
"/api/v2/raw",
params=[("source", "hermes,codex"), ("session_id", "s-1"), ("order", "asc")],
@@ -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.13"
assert manifest["snapshot_sdk_version"] == "2.0.14"
paths = {item["path"]: item for item in manifest["files"]}
assert {
"management/one-memory.sqlite3",
+4 -4
View File
@@ -991,7 +991,7 @@ wheels = [
[[package]]
name = "hy-memory"
version = "2.0.13"
version = "2.0.14"
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/88/46/7d5a2d76be135664302d91e15f0d5018cf8e228956e4c80b86583b131cb3/hy_memory-2.0.13.tar.gz", hash = "sha256:f95850d492de74e91891502716da96e8b5297c3fc045dc70ec3474ed240da958", size = 1252802, upload-time = "2026-11-02T09:30:40.331Z" }
sdist = { url = "https://files.pythonhosted.org/packages/4a/68/932ac0f684a971741643d8dab0a209c6a09d5a132ca607bbb9355e0b8cf2/hy_memory-2.0.14.tar.gz", hash = "sha256:94cf354f51ab8b506c0ac4909a95a6b13fd542f81c894322210beb20996f16fd", size = 1253180, upload-time = "2026-11-04T09:52:17.127Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/63/2b4d21d555a5dd50ab838fad944c7fdd6cf90d2dc814e2a8e79eca3d4e03/hy_memory-2.0.13-py3-none-any.whl", hash = "sha256:128cf4729e6ea10b9da3c7fdf21005191294621a65378cd9583827904eea064a", size = 1322154, upload-time = "2026-11-02T09:30:37.746Z" },
{ url = "https://files.pythonhosted.org/packages/76/7f/9ecbaa2589dae9bd83657adc6388b0c7b0be415c5584fef045db309911eb/hy_memory-2.0.14-py3-none-any.whl", hash = "sha256:6df76d8dc84aaa352149e85055331d22ef904e4acd4540a2328bf058a3f0f2a6", size = 1322364, upload-time = "2026-11-04T09:52:14.096Z" },
]
[[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.13" },
{ name = "hy-memory", specifier = "==2.0.14" },
{ name = "keyring", specifier = ">=25.7.0" },
{ name = "numpy", specifier = "==2.5.2" },
{ name = "onnxruntime", specifier = "==1.23.2" },
@@ -366,7 +366,7 @@ it("refuses a ui trace from an untrusted window", async () => {
).rejects.toThrow(/untrusted_window/);
});
it("marks a problem and exports a redacted local bundle", async () => {
it("marks a problem and exports the complete local log bundle", async () => {
const value = await fixture();
const mark = await value.handlers.get(
"one-memory:mark-diagnostic-incident",
@@ -375,6 +375,14 @@ it("marks a problem and exports a redacted local bundle", async () => {
incident_id: expect.any(String),
session_id: expect.any(String),
});
await writeFile(
path.join(value.dataDir, "logs", "core.log"),
"2026-10-14 10:31:00 WARNING database wait\n",
);
await writeFile(
path.join(value.dataDir, "logs", "service-error.log"),
"worker failed\n",
);
const exported = await value.handlers.get("one-memory:export-diagnostics")?.(
{},
);
@@ -398,6 +406,16 @@ it("marks a problem and exports a redacted local bundle", async () => {
),
);
expect(summary).toMatchObject({ build: "3800" });
const entries = execFileSync(
"/usr/bin/unzip",
["-Z1", path.join(value.dataDir, zip)],
{ encoding: "utf8" },
)
.trim()
.split("\n");
expect(entries).toContain("logs/core.log");
expect(entries).toContain("logs/service-error.log");
expect(entries).toContain("logs/diagnostic-desktop.jsonl");
value.dispose();
});
+3 -1
View File
@@ -196,6 +196,7 @@ export function resolveDiagnosticBuildIdentity(options: {
export async function exportDesktopDiagnostics(
options: DiagnosticExportOptions,
exportOptions: { includeRawLogs?: boolean } = {},
) {
const build = options.buildNumber ?? options.productVersion;
const target = path.join(
@@ -209,6 +210,7 @@ export async function exportDesktopDiagnostics(
dataDir: options.dataDir,
target,
productVersion: build,
includeRawLogs: exportOptions.includeRawLogs === true,
...(options.commit === undefined ? {} : { commit: options.commit }),
...(options.coreSha256 === undefined
? {}
@@ -644,7 +646,7 @@ export function installDiagnosticsIpc(
options.ipc.handle(EXPORT, async (event, ...args) => {
if (args.length) throw new Error("diagnostic_arguments_rejected");
options.requireTrustedWindow(event);
return exportDesktopDiagnostics(options);
return exportDesktopDiagnostics(options, { includeRawLogs: true });
});
return () => {
@@ -1235,7 +1235,9 @@ it("shows lite raw ingestion as running even when model sedimentation is paused"
/>,
);
expect(await screen.findAllByText("正在保存原始对话")).toHaveLength(2);
expect(await screen.findAllByText("正在保存")).toHaveLength(2);
expect(screen.getByText("暂无更新")).toBeTruthy();
expect(screen.getByText("已核对,暂无可显示的原始对话更新")).toBeTruthy();
expect(screen.getByText("原始对话持续保存,不需要生成模型")).toBeTruthy();
expect(screen.queryByText("已暂停")).toBeNull();
});
+1
View File
@@ -226,6 +226,7 @@ describe("HealthPanel", () => {
});
vi.spyOn(window, "prompt").mockReturnValue("蓝莓没出来");
render(<HealthPanel imports={imports()} />);
expect(screen.getByText(/可能包含会话正文和本机路径/)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "记录问题时间点" }));
expect(await screen.findByText(/问题编号已复制/)).toBeTruthy();
+1
View File
@@ -298,6 +298,7 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
<Download size={16} aria-hidden="true" />
{t("nativeExportLogs")}
</R6Button>
<p>{t("把本机运行日志打包成一个文件,发给开发排查。")}</p>
</div>
<section className="health-block" aria-label={t("healthLocal")}>
+41 -28
View File
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
import { legacyMemoryModePlan } from "@one-memory/operations";
import {
cleanup,
render,
@@ -64,31 +65,9 @@ function prepare(mode: "lite" | "pro" | "ultra" = "pro") {
running: [],
});
vi.mocked(getMemoryProcessing).mockResolvedValue({} as never);
vi.mocked(planMemoryMode).mockImplementation(async (target) => ({
current: mode,
target,
effective_target: target,
capabilities: {
content_plane: target === "lite" ? "raw" : "memory",
generation_required: target !== "lite",
graph_enabled: target === "ultra",
evolve_available: target === "ultra",
},
effects: [
...(target === "lite" ? (["higher_mode_memories_hidden"] as const) : []),
...(mode === "lite" && target !== "lite"
? (["earlier_raw_not_extracted", "lite_search_stops"] as const)
: []),
...(target === "ultra" && mode !== "ultra"
? (["existing_memories_added_to_graph"] as const)
: []),
...(mode === "ultra" && target !== "ultra"
? (["evolve_stopped"] as const)
: []),
],
warnings: [],
blockers: [],
}));
vi.mocked(planMemoryMode).mockImplementation(async (target) =>
legacyMemoryModePlan(mode, target, { hasActiveModel: true }),
);
}
async function chooseMode(
@@ -227,6 +206,20 @@ it("uses the isolated legacy plan only for a route-level old-Core 404", async ()
expect(await screen.findByText(/已有记忆继续保留/)).toBeTruthy();
});
it("keeps downgrade effects when an old Core falls back from ultra to pro", async () => {
prepare("ultra");
vi.mocked(planMemoryMode).mockRejectedValue(new Error("404"));
vi.mocked(memoryModePlanUnsupported).mockReturnValue(true);
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /智能记忆(pro)/);
const dialog = screen.getByRole("dialog");
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
expect(within(dialog).getByText("每天自动整合")).toBeTruthy();
});
it("explains model cost, search scope and the next step before switching to ultra", async () => {
prepare("lite");
const user = userEvent.setup();
@@ -238,6 +231,8 @@ it("explains model cost, search scope and the next step before switching to ultr
expect(screen.getByText("搜索范围")).toBeTruthy();
expect(screen.getByText("切换后")).toBeTruthy();
expect(screen.getByText(/已经处理完的旧对话不会补做/)).toBeTruthy();
expect(screen.getByText("知识图谱")).toBeTruthy();
expect(screen.getAllByText("已有数据")).toHaveLength(1);
expect(screen.getByText(/切换本身不会立即整合/)).toBeTruthy();
});
@@ -248,9 +243,27 @@ it("distinguishes regular memories from higher-level Ultra data when switching t
await chooseMode(user, /智能记忆(pro)/);
expect(screen.getByText(/普通沉淀记忆仍可使用/)).toBeTruthy();
expect(screen.getByText(/更高层记忆和图谱数据继续保留/)).toBeTruthy();
expect(screen.getByText(/每天自动整合会停止/)).toBeTruthy();
const dialog = screen.getByRole("dialog");
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
expect(within(dialog).getByText(/普通沉淀记忆仍可使用/)).toBeTruthy();
expect(within(dialog).getByText(/更高层记忆和图谱数据继续保留/)).toBeTruthy();
expect(within(dialog).getByText("每天自动整合")).toBeTruthy();
expect(
within(dialog).getByText("每天自动整合会停止;已生成的整合结果继续保留。"),
).toBeTruthy();
});
it("does not show automatic consolidation when switching from lite to pro", async () => {
prepare("lite");
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /智能记忆(pro)/);
const dialog = screen.getByRole("dialog");
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
expect(within(dialog).getByText(/已经处理完的旧对话不会补做/)).toBeTruthy();
expect(within(dialog).queryByText("每天自动整合")).toBeNull();
});
it("focuses the Evolve settings after switching to ultra", async () => {
+7 -5
View File
@@ -85,18 +85,20 @@ function planRows(plan: MemoryModePlan) {
if (plan.effects.includes("higher_mode_memories_hidden"))
rows.push({
label: "已有数据",
content: "已有记忆继续保留,但高级记忆在当前模式下不显示、不参与召回。",
content:
plan.effective_target === "pro"
? "普通沉淀记忆仍可使用;深度记忆新增的更高层记忆和图谱数据继续保留,但在智能记忆下不显示、不参与召回。原始对话不会删除。"
: "已有记忆继续保留,但在基础记忆下不显示、不参与召回;原始对话不会删除。",
});
if (plan.effects.includes("existing_memories_added_to_graph"))
rows.push({
label: "已有数据",
label: "知识图谱",
content: "已有记忆会在后台加入知识图谱,期间仍可使用。",
});
if (plan.effects.includes("evolve_stopped"))
rows.push({
label: "已有数据",
content:
"普通沉淀记忆仍可使用;更高层记忆和图谱数据继续保留,但不参与当前召回;每天自动整合会停止。",
label: "每天自动整合",
content: "每天自动整合会停止,已经生成的数据不会删除。",
});
if (plan.capabilities.evolve_available)
rows.push({
+89 -1
View File
@@ -387,6 +387,67 @@ it("pages and filters the raw conversation data plane in lite", async () => {
);
});
it("selects and renders one turn when two turns share a raw storage batch", async () => {
setup();
reads.processing.mockResolvedValue(modeProcessing("lite"));
reads.raw.mockResolvedValue({
items: [
{
raw_id: "raw-batch",
turn_id: "raw-turn:batch:0",
turn_index: 0,
turn_count: 2,
content: "[user]: 第一问\n[assistant]: 第一答",
user_id: "local",
agent_id: "all-apps",
source: "cursor",
session_id: "session-batch",
memory_at: null,
gmt_created: 1_761_000_000,
custom: {},
messages: [
{ role: "user", content: "第一问" },
{ role: "assistant", content: "第一答" },
],
},
{
raw_id: "raw-batch",
turn_id: "raw-turn:batch:1",
turn_index: 1,
turn_count: 2,
content: "[user]: 第二问\n[assistant]: 第二答",
user_id: "local",
agent_id: "all-apps",
source: "cursor",
session_id: "session-batch",
memory_at: null,
gmt_created: 1_761_000_000,
custom: {},
messages: [
{ role: "user", content: "第二问" },
{ role: "assistant", content: "第二答" },
],
},
],
total: 2,
limit: 20,
offset: 0,
unit: "turn",
});
const user = userEvent.setup();
render(<MemorySpace simplified />);
await user.click(await screen.findByRole("button", { name: /第二问/ }));
await waitFor(() => {
const detail = document.querySelector(".map-dialogue-reading");
expect(detail?.textContent).toContain("第二问");
expect(detail?.textContent).toContain("第二答");
expect(detail?.textContent).not.toContain("第一问");
expect(detail?.textContent).not.toContain("第一答");
});
});
it("switches data planes without keeping rows or details from the prior mode", async () => {
setup();
vi.useFakeTimers();
@@ -482,6 +543,27 @@ it("opens a search hit with the complete role transcript", async () => {
items: [
{
raw_id: "raw-search",
turn_id: "raw-turn:search:0",
turn_index: 0,
turn_count: 2,
content: "[user]: 第一轮问题\n[assistant]: 第一轮回答",
user_id: "local",
agent_id: "all-apps",
source: "cursor",
session_id: "session-search",
memory_at: null,
gmt_created: 1_761_000_000,
custom: {},
messages: [
{ role: "user", content: "第一轮问题" },
{ role: "assistant", content: "第一轮回答" },
],
},
{
raw_id: "raw-search",
turn_id: "raw-turn:search:1",
turn_index: 1,
turn_count: 2,
content: "[user]: 完整问题\n[assistant]: 完整回答",
user_id: "local",
agent_id: "all-apps",
@@ -496,7 +578,7 @@ it("opens a search hit with the complete role transcript", async () => {
],
},
],
total: 1,
total: 2,
limit: 500,
offset: 0,
}
@@ -506,6 +588,9 @@ it("opens a search hit with the complete role transcript", async () => {
{
kind: "raw",
raw_id: "raw-search",
turn_id: "raw-turn:search:1",
turn_index: 1,
turn_count: 2,
content: "被截断的搜索摘要",
source: "cursor",
session_id: "session-search",
@@ -525,6 +610,9 @@ it("opens a search hit with the complete role transcript", async () => {
expect(await screen.findByText("完整问题")).toBeTruthy();
expect(screen.getByText("完整回答")).toBeTruthy();
expect(
document.querySelector(".map-dialogue-reading")?.textContent,
).not.toContain("第一轮问题");
expect(reads.raw).toHaveBeenCalledWith(
expect.objectContaining({
source: "cursor",
+9 -3
View File
@@ -61,6 +61,7 @@ import {
type RawMapRecord,
rawDetail,
rawLibraryPage,
rawRecordId,
rawSearchPage,
} from "./sdk-raw-map";
import { sourceNameOr, UNATTRIBUTED_SOURCE_ID } from "./source-label";
@@ -277,8 +278,11 @@ export function MemorySpace({
const rememberRaw = (items: RawMapRecord[]) => {
const next = new Map<string, RawMapRecord>();
if (selectedRawRef.current)
next.set(selectedRawRef.current.raw_id, selectedRawRef.current);
for (const item of items) next.set(item.raw_id, item);
next.set(
rawRecordId(selectedRawRef.current),
selectedRawRef.current,
);
for (const item of items) next.set(rawRecordId(item), item);
rawRecords.current = next;
};
const [page, graph] = lite
@@ -717,7 +721,9 @@ export function MemorySpace({
offset,
limit: 500,
});
const full = page.items.find((item) => item.raw_id === memoryId);
const full = page.items.find(
(item) => rawRecordId(item) === memoryId,
);
if (full) {
record = full;
rawRecords.current.set(memoryId, full);
+280 -9
View File
@@ -72,6 +72,7 @@ const probeProfile = vi.fn();
const activate = vi.fn();
const remove = vi.fn();
const update = vi.fn();
const refreshProfiles = vi.fn();
const planActivation = vi.fn();
const startPlannedSwitch = vi.fn();
let switchPlan: EmbeddingSwitchPlan | null = null;
@@ -224,6 +225,7 @@ vi.mock("./use-model-profiles", () => ({
activate,
remove,
update,
refreshProfiles,
}),
}));
vi.mock("./use-embedding-switch", () => ({
@@ -279,6 +281,7 @@ describe("ModelSettings", () => {
switchCurrent = null;
create.mockResolvedValue(profile);
update.mockResolvedValue({ ...preset, has_api_key: true, revision: 2 });
refreshProfiles.mockResolvedValue(undefined);
planActivation.mockResolvedValue({
needed: false,
reason: "same_embedding",
@@ -576,7 +579,8 @@ describe("ModelSettings", () => {
name: "高级:自行配置向量模型",
});
await user.click(disclosure);
await user.selectOptions(screen.getByLabelText("向量配置模式"), "custom");
await user.click(screen.getByLabelText("向量配置模式"));
await user.click(screen.getByRole("option", { name: "自定义搜索模型" }));
await user.type(screen.getByLabelText("向量模型名"), "custom-vector");
await user.type(
screen.getByLabelText("向量模型 Base URL"),
@@ -666,9 +670,9 @@ describe("ModelSettings", () => {
];
render(<ModelSettings />);
await userEvent.click(screen.getByRole("button", { name: "编辑配置" }));
await userEvent.selectOptions(
screen.getByLabelText("向量配置模式"),
"local",
await userEvent.click(screen.getByLabelText("向量配置模式"));
await userEvent.click(
screen.getByRole("option", { name: "本地内置(推荐)" }),
);
await userEvent.click(screen.getByRole("button", { name: "保存修改" }));
expect(update).toHaveBeenCalledWith(profile.profile_id, {
@@ -687,9 +691,9 @@ describe("ModelSettings", () => {
name: "高级:自行配置向量模型",
});
await userEvent.click(disclosure);
await userEvent.selectOptions(
screen.getByLabelText("向量配置模式"),
"custom",
await userEvent.click(screen.getByLabelText("向量配置模式"));
await userEvent.click(
screen.getByRole("option", { name: "自定义搜索模型" }),
);
await userEvent.click(disclosure);
await userEvent.click(screen.getByRole("button", { name: "保存配置" }));
@@ -850,6 +854,190 @@ describe("ModelSettings", () => {
expect(activate).not.toHaveBeenCalled();
});
it("completes an embedding change only after the switch commit activates the target", async () => {
const onSetupComplete = vi.fn();
profiles = [
{
...profile,
capabilities: {
...profile.capabilities,
endpoint_reachable: true,
model_available: true,
embedding_available: true,
},
},
];
planActivation.mockImplementationOnce(async () => {
switchPlan = migrationPlan;
return migrationPlan;
});
const runningJob: EmbeddingSwitchJob = {
...oldFinishedJob,
job_id: "switch-job",
state: "reembedding",
progress: { ...oldFinishedJob.progress, phase: "vectors", done: 0 },
};
startPlannedSwitch.mockResolvedValue(runningJob);
switchCurrent = {
schema_version: 1,
phase: "idle",
writes_paused: false,
scan_state: "done",
scan_error: null,
job: null,
last_finished: oldFinishedJob,
};
const view = render(<ModelSettings onSetupComplete={onSetupComplete} />);
await userEvent.click(screen.getByRole("button", { name: "设为当前模型" }));
await userEvent.click(
await screen.findByRole("button", { name: "开始更新索引" }),
);
await waitFor(() =>
expect(startPlannedSwitch).toHaveBeenCalledExactlyOnceWith(),
);
expect(onSetupComplete).not.toHaveBeenCalled();
refreshProfiles.mockImplementationOnce(async () => {
profiles = [{ ...profiles[0], is_active: true }];
});
switchCurrent = {
...switchCurrent,
phase: "idle",
job: null,
last_finished: { ...runningJob, state: "done" },
};
view.rerender(<ModelSettings onSetupComplete={onSetupComplete} />);
await waitFor(() => expect(onSetupComplete).toHaveBeenCalledOnce());
});
it("does not complete a committed switch until the target revision is active", async () => {
const onSetupComplete = vi.fn();
profiles = [
{
...profile,
capabilities: {
...profile.capabilities,
endpoint_reachable: true,
model_available: true,
embedding_available: true,
},
},
];
planActivation.mockImplementationOnce(async () => {
switchPlan = migrationPlan;
return migrationPlan;
});
const runningJob: EmbeddingSwitchJob = {
...oldFinishedJob,
job_id: "switch-job",
state: "reembedding",
progress: { ...oldFinishedJob.progress, phase: "vectors", done: 0 },
};
startPlannedSwitch.mockResolvedValue(runningJob);
switchCurrent = {
schema_version: 1,
phase: "idle",
writes_paused: false,
scan_state: "done",
scan_error: null,
job: null,
last_finished: oldFinishedJob,
};
const view = render(<ModelSettings onSetupComplete={onSetupComplete} />);
await userEvent.click(screen.getByRole("button", { name: "设为当前模型" }));
await userEvent.click(
await screen.findByRole("button", { name: "开始更新索引" }),
);
await waitFor(() => expect(startPlannedSwitch).toHaveBeenCalledOnce());
switchCurrent = {
...switchCurrent,
phase: "idle",
job: null,
last_finished: { ...runningJob, state: "done" },
};
view.rerender(<ModelSettings onSetupComplete={onSetupComplete} />);
expect(
await screen.findByText(
"配置已保存,尚未确认启用成功。请核对配置后重试。",
),
).toBeTruthy();
expect(onSetupComplete).not.toHaveBeenCalled();
expect(
(
screen.getByRole("button", {
name: "保存并测试启用",
}) as HTMLButtonElement
).disabled,
).toBe(false);
});
it("keeps embedding configuration open when the switch rolls back", async () => {
const onSetupComplete = vi.fn();
profiles = [
{
...profile,
capabilities: {
...profile.capabilities,
endpoint_reachable: true,
model_available: true,
embedding_available: true,
},
},
];
planActivation.mockImplementationOnce(async () => {
switchPlan = migrationPlan;
return migrationPlan;
});
const runningJob: EmbeddingSwitchJob = {
...oldFinishedJob,
job_id: "switch-job",
state: "reembedding",
progress: { ...oldFinishedJob.progress, phase: "vectors", done: 0 },
};
startPlannedSwitch.mockResolvedValue(runningJob);
switchCurrent = {
schema_version: 1,
phase: "idle",
writes_paused: false,
scan_state: "done",
scan_error: null,
job: null,
last_finished: oldFinishedJob,
};
const view = render(<ModelSettings onSetupComplete={onSetupComplete} />);
await userEvent.click(screen.getByRole("button", { name: "设为当前模型" }));
await userEvent.click(
await screen.findByRole("button", { name: "开始更新索引" }),
);
await waitFor(() => expect(startPlannedSwitch).toHaveBeenCalledOnce());
switchCurrent = {
...switchCurrent,
phase: "idle",
job: null,
last_finished: {
...runningJob,
state: "rolled_back",
error_code: "embedding_switch_failed",
},
};
view.rerender(<ModelSettings onSetupComplete={onSetupComplete} />);
expect(
await screen.findByText(
"索引更新没有完成,仍在使用原来的搜索模型,记忆没有丢失。",
),
).toBeTruthy();
expect(onSetupComplete).not.toHaveBeenCalled();
});
it("hands a successful save to its card, clears only the key, and never tests or activates", async () => {
profiles = [];
const user = userEvent.setup();
@@ -1044,14 +1232,16 @@ describe("ModelSettings", () => {
await user.click(
screen.getByRole("button", { name: "高级:自行配置向量模型" }),
);
await user.selectOptions(screen.getByLabelText("向量配置模式"), "custom");
await user.click(screen.getByLabelText("向量配置模式"));
await user.click(screen.getByRole("option", { name: "自定义搜索模型" }));
await user.type(
screen.getByLabelText("配置名称"),
"Remote with local embedding",
);
await user.type(screen.getByLabelText("生成模型名"), "remote-model");
await user.type(screen.getByLabelText("向量模型名"), "nomic-embed-text");
await user.selectOptions(screen.getByLabelText("向量模型来源"), "ollama");
await user.click(screen.getByLabelText("向量模型来源"));
await user.click(screen.getByRole("option", { name: "本机 Ollama" }));
await user.type(
screen.getByLabelText("向量模型 Base URL"),
"http://127.0.0.1:11434",
@@ -1278,6 +1468,87 @@ describe("ModelSettings", () => {
expect(nameField().disabled).toBe(false);
});
});
it("only completes compact setup after the backend confirms the profile is active", async () => {
const user = userEvent.setup();
const onSetupComplete = vi.fn();
const capabilities = {
endpoint_reachable: true,
model_available: true,
structured_output: false,
embedding_available: true,
};
const created = {
...profile,
profile_id: "created",
revision: 1,
capabilities,
};
profiles = [];
create.mockImplementation(async () => {
profiles = [created];
return created;
});
probeProfile.mockResolvedValue({
profile_id: created.profile_id,
profile_revision: created.revision,
stages: [],
capabilities,
});
activate.mockResolvedValue({ ...created, is_active: true });
render(<ModelSettings compactSingle onSetupComplete={onSetupComplete} />);
await user.type(screen.getByLabelText("配置名称"), "Current model");
await user.type(screen.getByLabelText("生成模型名"), "model-v1");
await user.type(screen.getByLabelText(/^API Key/), "secret-value");
await user.click(screen.getByRole("button", { name: "测试并完成配置" }));
await waitFor(() => expect(onSetupComplete).toHaveBeenCalledOnce());
expect(planActivation).toHaveBeenCalledWith(
created.profile_id,
created.revision,
);
expect(activate).toHaveBeenCalledWith(created.profile_id);
});
it("keeps compact setup open when activation is not confirmed", async () => {
const user = userEvent.setup();
const onSetupComplete = vi.fn();
const capabilities = {
endpoint_reachable: true,
model_available: true,
structured_output: false,
embedding_available: true,
};
const created = {
...profile,
profile_id: "created",
revision: 1,
capabilities,
};
profiles = [];
create.mockImplementation(async () => {
profiles = [created];
return created;
});
probeProfile.mockResolvedValue({
profile_id: created.profile_id,
profile_revision: created.revision,
stages: [],
capabilities,
});
activate.mockResolvedValue({ ...created, is_active: false });
render(<ModelSettings compactSingle onSetupComplete={onSetupComplete} />);
await user.type(screen.getByLabelText("配置名称"), "Inactive model");
await user.type(screen.getByLabelText("生成模型名"), "model-v1");
await user.type(screen.getByLabelText(/^API Key/), "secret-value");
await user.click(screen.getByRole("button", { name: "测试并完成配置" }));
await screen.findByText("配置已保存,尚未确认启用成功。请核对配置后重试。");
expect(onSetupComplete).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "测试并完成配置" })).toBeTruthy();
});
});
describe("把一张预置卡设为当前模型", () => {
+113 -34
View File
@@ -11,6 +11,7 @@ import {
type FormEvent,
type Ref,
useEffect,
useId,
useImperativeHandle,
useLayoutEffect,
useRef,
@@ -26,6 +27,7 @@ import {
presetForGeneration,
validateGenerationModel,
} from "./GenerationModelPrimitives";
import { OptionMenu } from "./OptionMenu";
import { useOnboardingTranslation } from "./onboarding-language";
import { PROVIDER_PRESETS } from "./provider-presets";
import { R6StateRecovery } from "./R6StateRecovery";
@@ -238,6 +240,7 @@ export function ModelSettings({
onSetupComplete?(): void;
} = {}) {
const { text, t } = useOnboardingTranslation();
const fieldId = useId();
const models = useModelProfiles();
const embeddingSwitch = useEmbeddingSwitch();
const [provider, setProvider] = useState<ProviderKind>("openai_compatible");
@@ -275,6 +278,9 @@ export function ModelSettings({
const [setupError, setSetupError] = useState<string | null>(null);
const [switchTarget, setSwitchTarget] = useState<ModelProfile | null>(null);
const [switchJobId, setSwitchJobId] = useState<string | null>(null);
const [switchRefreshCompleted, setSwitchRefreshCompleted] = useState<
string | null
>(null);
const settledSwitchJob = useRef<string | null>(null);
const flight = useRef(false);
const alive = useRef(true);
@@ -308,10 +314,11 @@ export function ModelSettings({
return;
settledSwitchJob.current = finished.job_id;
if (finished.state === "done") {
void models.refreshProfiles();
setSetupPhase("activating");
setSetupError(null);
setSwitchTarget(null);
setSwitchJobId(null);
void models.refreshProfiles().then(() => {
if (alive.current) setSwitchRefreshCompleted(finished.job_id);
});
onProfilesChanged?.();
return;
}
@@ -323,6 +330,7 @@ export function ModelSettings({
);
setSwitchTarget(null);
setSwitchJobId(null);
setSwitchRefreshCompleted(null);
}
}, [
embeddingSwitch.current?.last_finished,
@@ -331,6 +339,45 @@ export function ModelSettings({
switchJobId,
switchTarget,
]);
useEffect(() => {
const finished = embeddingSwitch.current?.last_finished;
if (
!switchTarget ||
!switchJobId ||
finished?.job_id !== switchJobId ||
finished.state !== "done" ||
switchRefreshCompleted !== switchJobId
)
return;
const active = models.profiles.find(
(profile) =>
profile.profile_id === switchTarget.profile_id &&
profile.revision >= switchTarget.revision &&
profile.is_active,
);
if (!active) {
setSetupPhase(null);
setSetupError("配置已保存,尚未确认启用成功。请核对配置后重试。");
setSwitchTarget(null);
setSwitchJobId(null);
setSwitchRefreshCompleted(null);
return;
}
if (active.name !== PRESET_PROFILE_NAME) setSaved(active);
setSetupPhase(null);
setSetupError(null);
setSwitchTarget(null);
setSwitchJobId(null);
setSwitchRefreshCompleted(null);
onSetupComplete?.();
}, [
embeddingSwitch.current?.last_finished,
models.profiles,
onSetupComplete,
switchJobId,
switchRefreshCompleted,
switchTarget,
]);
useLayoutEffect(() => {
if (saved) savedCard.current?.focus({ preventScroll: true });
}, [saved]);
@@ -381,6 +428,7 @@ export function ModelSettings({
saved);
const editorLocked =
saving ||
switchJobId !== null ||
models.busy !== null ||
!!models.pendingCreation ||
!!models.pendingProbe ||
@@ -864,6 +912,9 @@ export function ModelSettings({
if (!alive.current) return;
if (created) {
if (thenSetup) {
// Retrying after probe, activation, or switch confirmation failed
// must update this saved revision instead of creating a duplicate.
setEditing(created);
// The probe decides whether this form closes. A rejected key or a
// failed endpoint leaves the filled-in form on screen with the
// reason; only a passing test returns to the saved card.
@@ -1339,20 +1390,37 @@ export function ModelSettings({
)}
{focusSection === "embedding" || advancedEmbedding ? (
<>
<label>
{text(
focusSection === "embedding"
? "搜索模型"
: "向量配置模式",
)}
<select
name="embedding-mode"
<div className="model-form-field">
<label
className="model-option-label"
id={`${fieldId}-embedding-mode-label`}
htmlFor={`${fieldId}-embedding-mode`}
>
{text(
focusSection === "embedding"
? "搜索模型"
: "向量配置模式",
)}
</label>
<OptionMenu
id={`${fieldId}-embedding-mode`}
labelledBy={`${fieldId}-embedding-mode-label`}
value={embeddingMode}
disabled={formLocked}
onChange={(event) => {
setEmbeddingMode(
event.target.value as "local" | "custom",
);
className="model-option-menu"
panelClassName="model-option-menu-panel"
options={[
{
value: "local",
label: text("本地内置(推荐)"),
},
{
value: "custom",
label: text("自定义搜索模型"),
},
]}
onChange={(value) => {
setEmbeddingMode(value as "local" | "custom");
setEmbeddingModeChanged(true);
setFields((previous) => ({
...previous,
@@ -1360,11 +1428,8 @@ export function ModelSettings({
"embedding-base-url": undefined,
}));
}}
>
<option value="local">{text("本地内置(推荐)")}</option>
<option value="custom">{text("自定义搜索模型")}</option>
</select>
</label>
/>
</div>
<small>{text("技术类型:Embedding")}</small>
{embeddingMode === "custom" ? (
<>
@@ -1397,25 +1462,38 @@ export function ModelSettings({
</small>
)}
</label>
<label>
{text("向量模型来源")}
<select
name="embedding-provider"
<div className="model-form-field">
<label
className="model-option-label"
id={`${fieldId}-embedding-provider-label`}
htmlFor={`${fieldId}-embedding-provider`}
>
{text("向量模型来源")}
</label>
<OptionMenu
id={`${fieldId}-embedding-provider`}
labelledBy={`${fieldId}-embedding-provider-label`}
value={embeddingProvider}
disabled={formLocked}
onChange={(event) => {
const next = event.target.value as
className="model-option-menu"
panelClassName="model-option-menu-panel"
options={[
{ value: "ollama", label: text("本机 Ollama") },
{
value: "openai_compatible",
label: text(
"同源 OpenAI-compatible(共用 API Key)",
),
},
]}
onChange={(value) => {
const next = value as
| "openai_compatible"
| "ollama";
setEmbeddingProvider(next);
}}
>
<option value="ollama">{text("本机 Ollama")}</option>
<option value="openai_compatible">
{text("同源 OpenAI-compatible(共用 API Key)")}
</option>
</select>
</label>
/>
</div>
<label>
{text("向量模型 Base URL")}
<input
@@ -1669,13 +1747,14 @@ export function ModelSettings({
embeddingSwitch.clearError();
setSwitchTarget(null);
setSwitchJobId(null);
setSwitchRefreshCompleted(null);
}}
onStart={() => {
void embeddingSwitch.startPlannedSwitch().then((job) => {
if (!job || !alive.current) return;
setSwitchJobId(job.job_id);
setSwitchRefreshCompleted(null);
setSetupError(null);
onSetupComplete?.();
});
}}
/>
+1 -1
View File
@@ -111,7 +111,7 @@ export function OptionMenu(props: {
function choose(index: number) {
const item = props.options[index];
if (!item || item.disabled) return;
props.onChange(item.value);
if (item.value !== props.value) props.onChange(item.value);
setOpen(false);
button.current?.focus();
}
+48 -5
View File
@@ -21,6 +21,9 @@ import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { queryClient, resetClient } from "./query-client";
import { R6Settings } from "./R6Settings";
const editProfileAction = vi.fn(() => true);
const addProfileAction = vi.fn(() => true);
it("changes settings copy live without rereading profiles or changing permissions", async () => {
const user = userEvent.setup();
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
@@ -112,8 +115,8 @@ vi.mock("./ModelSettings", async () => {
}) => {
useImperativeHandle(controlsRef, () => ({
edit: () => false,
editProfile: () => false,
add: () => false,
editProfile: editProfileAction,
add: addProfileAction,
test: () => Promise.resolve(undefined),
}));
return createElement(
@@ -166,6 +169,7 @@ it("shows the help and diagnostics group by default and hides it when opted out"
await user.click(screen.getByRole("button", { name: "帮助与诊断" }));
expect(screen.getByRole("heading", { name: "帮助与诊断" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "导出日志" })).toBeTruthy();
expect(screen.getByText(/可能包含会话正文和本机路径/)).toBeTruthy();
expect(screen.getByRole("heading", { name: "关于" })).toBeTruthy();
expect(screen.getByText("1.0.4")).toBeTruthy();
expect(screen.getByText("8")).toBeTruthy();
@@ -304,6 +308,7 @@ it("reports a stop that did not confirm instead of showing it stopped", async ()
});
it("opens the model area even when a pending probe fences the editor, so recovery stays reachable", async () => {
editProfileAction.mockReturnValueOnce(false);
const user = userEvent.setup();
render(<R6Settings onDiagnostics={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "模型配置" }));
@@ -321,10 +326,13 @@ it("opens the model area even when a pending probe fences the editor, so recover
expect(alert.textContent).toContain("待核对");
// What changed is that the recovery entry no longer hides with the form it
// is supposed to unlock.
expect(screen.getByRole("button", { name: "核对测试结果" })).toBeTruthy();
expect(
within(generationLane).getByRole("button", { name: "核对测试结果" }),
).toBeTruthy();
});
it("reaches the same recovery entry when no model has been configured yet", async () => {
addProfileAction.mockReturnValueOnce(false);
vi.mocked(getModelProfiles).mockResolvedValueOnce([]);
const user = userEvent.setup();
render(<R6Settings onDiagnostics={vi.fn()} />);
@@ -368,11 +376,46 @@ it("shows separate compact lanes and opens the requested presentation", async ()
expect(embeddingLane.textContent).toContain("Embedding");
await user.click(within(embeddingLane).getByRole("button", { name: "配置" }));
expect(screen.getByText("embedding")).toBeTruthy();
expect(await within(embeddingLane).findByText("embedding")).toBeTruthy();
expect(editProfileAction).toHaveBeenCalledTimes(1);
expect(editProfileAction).toHaveBeenLastCalledWith(
expect.objectContaining({ profile_id: "profile-1" }),
);
expect(embeddingLane.dataset.expanded).toBe("true");
expect(
within(embeddingLane)
.getByRole("button", { name: "收起" })
.getAttribute("aria-expanded"),
).toBe("true");
await user.click(
within(generationLane).getByRole("button", { name: "配置" }),
);
expect(screen.getByText("generation")).toBeTruthy();
expect(await within(generationLane).findByText("generation")).toBeTruthy();
expect(editProfileAction).toHaveBeenCalledTimes(2);
expect(generationLane.dataset.expanded).toBe("true");
expect(within(embeddingLane).queryByText("embedding")).toBeNull();
await user.click(
within(generationLane).getByRole("button", { name: "收起" }),
);
expect(
within(generationLane)
.getByText("generation")
.closest(".r6-model-owner")
?.hasAttribute("hidden"),
).toBe(true);
await user.click(
within(generationLane).getByRole("button", { name: "配置" }),
);
expect(editProfileAction).toHaveBeenCalledTimes(2);
expect(
within(generationLane)
.getByText("generation")
.closest(".r6-model-owner")
?.hasAttribute("hidden"),
).toBe(false);
expect(screen.queryByRole("alert")).toBeNull();
});
it("leaves the switch unusable when the state cannot be read", async () => {
+187 -82
View File
@@ -78,6 +78,7 @@ export function R6Settings({
const [configurationOpen, setConfigurationOpen] = useState(false);
const [configurationFocus, setConfigurationFocus] =
useState<ModelSettingsFocusSection>("generation");
const [configurationRequest, setConfigurationRequest] = useState(0);
const [memoryMode, setMemoryModeState] = useState<MemoryPipelineMode | null>(
null,
);
@@ -101,6 +102,10 @@ export function R6Settings({
const [sedimentationUnavailable, setSedimentationUnavailable] =
useState(false);
const modelActions = useRef<ModelSettingsActions>(null);
const configurationStarted = useRef(false);
const configurationStartedFocus = useRef<ModelSettingsFocusSection | null>(
null,
);
const quitPanel = useRef<HTMLElement>(null);
const quitOpener = useRef<HTMLElement | null>(null);
const currentModel = profiles?.find((profile) => profile.is_active) ?? null;
@@ -224,18 +229,49 @@ export function R6Settings({
function openConfiguration(
focusSection: ModelSettingsFocusSection = "generation",
) {
if (
configurationStarted.current &&
configurationStartedFocus.current === focusSection
) {
setConfigurationFocus(focusSection);
setConfigurationOpen(true);
return;
}
setConfigurationRefusal(false);
setConfigurationFocus(focusSection);
configurationStarted.current = true;
configurationStartedFocus.current = focusSection;
setConfigurationOpen(true);
setConfigurationRequest((value) => value + 1);
}
// The focused editor can move between the two lanes. Fill it only after
// React has mounted it in the requested lane, otherwise an edit sent to the
// old lane would be lost during the move.
// biome-ignore lint/correctness/useExhaustiveDependencies: configurationRequest is the explicit intent trigger.
useEffect(() => {
if (!configurationOpen || configurationRequest === 0) return;
const opened = currentModel
? modelActions.current?.editProfile(currentModel)
: modelActions.current?.add();
// The area opens even when the editor refuses: the only recovery entry
// lives inside it, and hiding both is what locked people out of their own
// settings. They needed the form open to reach “核对测试结果”, and that
// button to clear the pending record that kept the form shut. The fence
// itself is untouched (see ModelSettings): refusing still returns false.
setConfigurationOpen(true);
// The area stays open even when the editor refuses: its recovery entry is
// the only way to reconcile the pending operation.
if (!opened) setConfigurationRefusal(true);
}, [configurationRequest]);
function toggleConfiguration(focusSection: ModelSettingsFocusSection) {
if (configurationOpen && configurationFocus === focusSection) {
setConfigurationOpen(false);
return;
}
openConfiguration(focusSection);
}
function completeConfiguration() {
configurationStarted.current = false;
configurationStartedFocus.current = null;
setConfigurationRefusal(false);
setConfigurationOpen(false);
}
async function quitApplication() {
@@ -384,67 +420,156 @@ export function R6Settings({
</div>
)}
<div className="r6-model-overview">
<div className="r6-model-lane">
<div className="r6-model-lane-copy">
<h3>{text("记忆整理")}</h3>
<p>{text("把会话理解并整理成可长期使用的记忆。")}</p>
<strong>
{currentModel?.name ??
text(
profiles === null
? "正在读取模型…"
: modelError
? "模型状态暂不可读"
: "尚未配置模型",
<section
className="r6-model-lane"
data-expanded={
configurationOpen && configurationFocus === "generation"
? "true"
: undefined
}
>
<div className="r6-model-lane-header">
<div className="r6-model-lane-copy">
<h3>{text("记忆整理")}</h3>
<p>{text("把会话理解并整理成可长期使用的记忆。")}</p>
<strong>
{currentModel?.name ??
text(
profiles === null
? "正在读取模型…"
: modelError
? "模型状态暂不可读"
: "尚未配置模型",
)}
</strong>
{currentModel && (
<small>
LLM · {currentModel.model} ·{" "}
{text(safeEndpoint(currentModel.base_url))}
</small>
)}
</div>
<R6Button
variant="quiet"
disabled={profiles === null}
aria-expanded={
configurationOpen && configurationFocus === "generation"
}
aria-controls={
configurationFocus === "generation"
? `${id}-generation-configuration`
: undefined
}
onClick={() => toggleConfiguration("generation")}
>
{configurationOpen && configurationFocus === "generation"
? t("wb.collapse")
: text(currentModel ? "配置" : "配置模型")}
</R6Button>
</div>
{configurationFocus === "generation" && (
<section
id={`${id}-generation-configuration`}
className="r6-model-owner"
hidden={!configurationOpen}
aria-label={text("模型配置表单")}
>
{configurationRefusal && (
<p role="alert" className="r6-settings-error">
{text(
"上次模型测试或创建还待核对,配置暂时不能改动。请先在下方模型配置区核对结果; 现有配置和原文不会被改动。",
)}
</p>
)}
<ModelSettings
compactSingle
focusSection={configurationFocus}
controlsRef={modelActions}
onProfilesChanged={() =>
setModelVersion((value) => value + 1)
}
onSetupComplete={completeConfiguration}
/>
</section>
)}
</section>
<section
className="r6-model-lane"
data-expanded={
configurationOpen && configurationFocus === "embedding"
? "true"
: undefined
}
>
<div className="r6-model-lane-header">
<div className="r6-model-lane-copy">
<h3>{text("记忆搜索")}</h3>
<p>{text("在需要时找到与你的问题最相关的记忆。")}</p>
<strong>
{text(
currentModel?.embedding_provider_kind &&
currentModel.embedding_provider_kind !==
"local_onnx" &&
currentModel.embedding_model
? currentModel.embedding_model
: "本地内置(推荐)",
)}
</strong>
{currentModel && (
</strong>
<small>
LLM · {currentModel.model} ·{" "}
{text(safeEndpoint(currentModel.base_url))}
{text(
currentModel?.embedding_provider_kind &&
currentModel.embedding_provider_kind !== "local_onnx"
? `Embedding · ${currentModel.embedding_provider_kind}`
: profiles !== null && !currentModel
? "Embedding · 配置记忆整理后可自定义"
: "Embedding · 本机处理,无需额外配置",
)}
</small>
)}
</div>
<R6Button
variant="quiet"
disabled={profiles === null || !currentModel}
aria-expanded={
configurationOpen && configurationFocus === "embedding"
}
aria-controls={
configurationFocus === "embedding"
? `${id}-embedding-configuration`
: undefined
}
onClick={() => toggleConfiguration("embedding")}
>
{configurationOpen && configurationFocus === "embedding"
? t("wb.collapse")
: text("配置")}
</R6Button>
</div>
<R6Button
variant="quiet"
disabled={profiles === null}
onClick={() => openConfiguration("generation")}
>
{text(currentModel ? "配置" : "配置模型")}
</R6Button>
</div>
<div className="r6-model-lane">
<div className="r6-model-lane-copy">
<h3>{text("记忆搜索")}</h3>
<p>{text("在需要时找到与你的问题最相关的记忆。")}</p>
<strong>
{text(
currentModel?.embedding_provider_kind &&
currentModel.embedding_provider_kind !== "local_onnx" &&
currentModel.embedding_model
? currentModel.embedding_model
: "本地内置(推荐)",
{configurationFocus === "embedding" && (
<section
id={`${id}-embedding-configuration`}
className="r6-model-owner"
hidden={!configurationOpen}
aria-label={text("模型配置表单")}
>
{configurationRefusal && (
<p role="alert" className="r6-settings-error">
{text(
"上次模型测试或创建还待核对,配置暂时不能改动。请先在下方模型配置区核对结果; 现有配置和原文不会被改动。",
)}
</p>
)}
</strong>
<small>
{text(
currentModel?.embedding_provider_kind &&
currentModel.embedding_provider_kind !== "local_onnx"
? `Embedding · ${currentModel.embedding_provider_kind}`
: profiles !== null && !currentModel
? "Embedding · 配置记忆整理后可自定义"
: "Embedding · 本机处理,无需额外配置",
)}
</small>
</div>
<R6Button
variant="quiet"
disabled={profiles === null || !currentModel}
onClick={() => openConfiguration("embedding")}
>
{text("配置")}
</R6Button>
</div>
<ModelSettings
compactSingle
focusSection={configurationFocus}
controlsRef={modelActions}
onProfilesChanged={() =>
setModelVersion((value) => value + 1)
}
onSetupComplete={completeConfiguration}
/>
</section>
)}
</section>
</div>
{modelError && (
<R6StateRecovery
@@ -456,13 +581,6 @@ export function R6Settings({
onRetry={() => setModelVersion((value) => value + 1)}
/>
)}
{configurationRefusal && (
<p role="alert" className="r6-settings-error">
{text(
"上次模型测试或创建还待核对,配置暂时不能改动。请先在下方模型配置区核对结果; 现有配置和原文不会被改动。",
)}
</p>
)}
{currentModel && memoryMode !== "lite" && (
<div className="r6-setting-row">
<div>
@@ -499,19 +617,6 @@ export function R6Settings({
)}
</p>
)}
<section
className="r6-model-owner"
hidden={!configurationOpen}
aria-label={text("模型配置表单")}
>
<ModelSettings
compactSingle
focusSection={configurationFocus}
controlsRef={modelActions}
onProfilesChanged={() => setModelVersion((value) => value + 1)}
onSetupComplete={() => setConfigurationOpen(false)}
/>
</section>
</section>
<section
id={`${id}-panel-connections`}
@@ -0,0 +1,22 @@
import { readFileSync } from "node:fs";
import { expect, it } from "vitest";
function declaration(file: string, selector: string): string {
const css = readFileSync(new URL(`./${file}`, import.meta.url), "utf8");
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return css.match(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`))?.[1] ?? "";
}
it("fills settings controls without widening shared fallback controls", () => {
for (const selector of [
".r6-memory-mode-settings .memory-mode-choice-list",
".r6-settings .connection-fallback .custom-harness-name",
]) {
const rule = declaration("r6-settings.css", selector);
expect(rule, selector).toMatch(/(?:^|[\s;])width:\s*100%\s*;/);
expect(rule, selector).toMatch(/(?:^|[\s;])max-width:\s*none\s*;/);
}
expect(declaration("core-visual.css", ".custom-harness-name")).toMatch(
/(?:^|[\s;])max-width:\s*420px\s*;/,
);
});
+5 -1
View File
@@ -3,6 +3,7 @@ import { type FormEvent, useEffect, useRef, useState } from "react";
import { getSdkRaw, searchSdkRaw } from "./api";
import { useGuiLanguage } from "./gui-language";
import { subscribePoll } from "./poll-scheduler";
import { rawRecordId } from "./sdk-raw-map";
export function SdkRawHistory({ active = true }: { active?: boolean }) {
const { locale, t, tn } = useGuiLanguage();
@@ -121,7 +122,7 @@ export function SdkRawHistory({ active = true }: { active?: boolean }) {
) : null}
<ol>
{items.map((item) => (
<li key={item.raw_id}>
<li key={rawRecordId(item)}>
<p>{item.content}</p>
<small>
{item.source} · {item.session_id}
@@ -140,6 +141,9 @@ function asContextItem(item: SdkRawRecord): SdkRawContextItem {
return {
kind: "raw",
raw_id: item.raw_id,
turn_id: item.turn_id,
turn_index: item.turn_index,
turn_count: item.turn_count,
content: item.content,
source: item.source,
session_id: item.session_id,
+2 -2
View File
@@ -5,7 +5,7 @@ import type {
} from "@one-memory/contracts";
import { clockStamp } from "./clock-stamp";
import { useGuiLanguage } from "./gui-language";
import { rawRecordTime } from "./sdk-raw-map";
import { rawRecordId, rawRecordTime } from "./sdk-raw-map";
import { sourceNameOr, UNATTRIBUTED_SOURCE_ID } from "./source-label";
type RawRecord = SdkRawRecord | SdkRawContextItem;
@@ -50,7 +50,7 @@ export function SelectedRawDetail({
<p className="selected-memory-status">{t("memory:原始对话")}</p>
<ul className="map-transcript">
{messages.map((message) => (
<li key={`${record.raw_id}:${message.role}:${message.content}`}>
<li key={`${rawRecordId(record)}:${message.role}:${message.content}`}>
<small>{authorLabel(message.role, t)}</small>
<p>{message.content}</p>
</li>
+2
View File
@@ -32,6 +32,7 @@ it("lists raw conversations for the selected application", async () => {
session_id: undefined,
offset: 20,
limit: 20,
unit: "turn",
},
signal,
});
@@ -49,6 +50,7 @@ it("keeps the application filter when searching raw conversations", async () =>
query: "release plan",
kinds: ["raw"],
include_sources: ["claude-code"],
raw_unit: "turn",
limit: 20,
character_budget: 12_000,
},
+4 -1
View File
@@ -986,16 +986,18 @@ export async function getSdkRaw({
sessionId,
offset = 0,
limit = 100,
unit = "turn",
signal,
}: {
source?: string;
sessionId?: string;
offset?: number;
limit?: number;
unit?: "raw" | "turn";
signal?: AbortSignal;
} = {}): Promise<SdkRawPage> {
const response = await client.get<SdkRawPage>("/api/v2/raw", {
params: { source, session_id: sessionId, offset, limit },
params: { source, session_id: sessionId, offset, limit, unit },
signal,
});
return response.data;
@@ -1011,6 +1013,7 @@ export async function searchSdkRaw(
query,
kinds: ["raw"],
...(options.source ? { include_sources: [options.source] } : {}),
raw_unit: "turn",
limit: 20,
character_budget: 12_000,
},
+7 -2
View File
@@ -132,12 +132,17 @@ button svg {
content: "";
position: absolute;
right: 18px;
top: 22px;
top: 50%;
width: 8px;
height: 8px;
border-right: 1.5px solid #4e6478;
border-bottom: 1.5px solid #4e6478;
transform: rotate(45deg);
transform: translateY(-65%) rotate(45deg);
transform-origin: 50% 50%;
transition: transform 150ms ease-out;
}
.om-option-menu-button[aria-expanded="true"]::after {
transform: translateY(-35%) rotate(225deg);
}
.om-option-menu-button:disabled {
color: #66727b;
+4 -3
View File
@@ -38,7 +38,7 @@ const base = {
describe("home view model", () => {
it.each([
["lite", "raw", "apps.rawConversations", "apps.rawSaving"],
["lite", "raw", "apps.rawConversations", "apps.rawSavingCompact"],
["pro", "memory", "apps.memories", "apps.running"],
["ultra", "memory", "apps.memories", "apps.running"],
] as const)(
@@ -109,7 +109,7 @@ describe("home view model", () => {
} as never,
});
expect(result.recentMetric).toEqual({
valueKey: "apps.rawCheckedEmpty",
valueKey: "apps.recentEmptyCompact",
detailKey: "apps.checkedRecent",
});
expect(result.activity).toMatchObject({
@@ -158,9 +158,10 @@ describe("home view model", () => {
projectionState: "ready",
});
expect(result.recentMetric).toEqual({
valueKey: "apps.memoryCheckedEmpty",
valueKey: "apps.recentEmptyCompact",
detailKey: "apps.checkedRecent",
});
expect(result.activity.titleKey).toBe("apps.memoryCheckedEmpty");
expect(result.activity.kind).toBe("empty");
});
+3 -10
View File
@@ -40,11 +40,7 @@ export interface HomeViewModel {
emptyExcerpt: boolean;
} | null;
recentMetric: {
valueKey:
| "apps.checkingRecent"
| "apps.rawCheckedEmpty"
| "apps.memoryCheckedEmpty"
| null;
valueKey: "apps.checkingRecent" | "apps.recentEmptyCompact" | null;
detailKey:
| "apps.rawSaved"
| "apps.saved"
@@ -120,7 +116,7 @@ export function buildHomeViewModel(input: {
const sedimenting = input.sedimentation?.enabled;
const processingLabel =
!generationRequired || mode === "lite"
? "apps.rawSaving"
? "apps.rawSavingCompact"
: sedimenting === true
? "apps.running"
: sedimenting === false
@@ -179,10 +175,7 @@ export function buildHomeViewModel(input: {
}
: state === "ready" || state === "stale"
? {
valueKey:
plane === "raw"
? ("apps.rawCheckedEmpty" as const)
: ("apps.memoryCheckedEmpty" as const),
valueKey: "apps.recentEmptyCompact" as const,
detailKey: "apps.checkedRecent" as const,
}
: {
-1
View File
@@ -76,7 +76,6 @@
text-overflow: ellipsis;
}
.onboarding-r6-titlebar .r6-language-option-menu .om-option-menu-button::after {
top: 10px;
right: 11px;
}
.onboarding-r6-titlebar
+53 -9
View File
@@ -66,7 +66,6 @@
text-overflow: ellipsis;
}
.r6-settings .r6-language-option-menu .om-option-menu-button::after {
top: 16px;
right: 16px;
}
.r6-settings
@@ -229,7 +228,8 @@
line-height: 22px;
}
.r6-memory-mode-settings .memory-mode-choice-list {
max-width: 760px;
width: 100%;
max-width: none;
}
.r6-settings-note {
margin: 14px 0 0;
@@ -482,7 +482,8 @@
text-wrap: pretty;
}
.r6-settings .connection-fallback .custom-harness-name {
max-width: 560px;
width: 100%;
max-width: none;
gap: 8px;
margin: 0 0 14px;
color: #4f6373;
@@ -596,6 +597,12 @@
background: #fff;
}
.r6-settings .r6-model-lane {
min-height: 108px;
}
.r6-settings .r6-model-lane[data-expanded="true"] {
background: #f8fafb;
}
.r6-settings .r6-model-lane-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
@@ -639,7 +646,7 @@
font-size: 12px;
line-height: 20px;
}
.r6-settings .r6-model-lane > .r6-button {
.r6-settings .r6-model-lane-header > .r6-button {
min-width: 72px;
}
.r6-settings .r6-model-recipient {
@@ -690,8 +697,9 @@
line-height: 28px;
}
.r6-settings .r6-model-owner {
padding-top: 24px;
padding: 24px 20px;
border-top: 1px solid #d8e2ea;
background: #f8fafb;
}
.r6-settings .r6-model-owner[hidden] {
display: none;
@@ -847,7 +855,6 @@
font-size: 14px;
}
.r6-settings .model-form .om-option-menu-button::after {
top: 16px;
right: 16px;
}
.r6-settings .model-form .om-option-menu-button[aria-expanded="true"] {
@@ -866,6 +873,39 @@
outline: 2px solid var(--r6-focus);
outline-offset: 2px;
}
.r6-settings .model-option-label {
display: block;
margin: 0;
}
.model-option-menu-panel {
padding: 6px;
border-color: var(--r6-control-edge, #c0ccd5);
box-shadow: 0 12px 32px rgb(48 71 87 / 13%);
}
.model-option-menu-panel [role="option"] {
min-height: 40px;
padding: 8px 10px;
color: #304757;
font-size: 14px;
line-height: 22px;
}
.model-option-menu-panel [role="option"][aria-selected="true"] {
background: #dfe9ef;
font-weight: 500;
}
.om-option-menu-panel.model-option-menu-panel
[role="option"][data-active="true"] {
color: #304757;
background: #e9f0f4;
}
.om-option-menu-panel.model-option-menu-panel
[role="option"]:hover:not(:disabled) {
color: #304757;
background: #e9f0f4;
}
.model-option-menu-panel [role="option"]:active:not(:disabled) {
background: #d4e2ea;
}
.r6-settings .model-advanced {
grid-column: 1 / -1;
}
@@ -884,7 +924,8 @@
font-size: 13px;
line-height: 21px;
}
.r6-settings .model-advanced label {
.r6-settings .model-advanced label,
.r6-settings .model-advanced .model-form-field {
display: flex;
flex-direction: column;
gap: 8px;
@@ -988,14 +1029,17 @@
padding: 24px 20px;
border-radius: 0 0 11px 11px;
}
.r6-settings .r6-model-lane {
.r6-settings .r6-model-lane-header {
grid-template-columns: minmax(0, 1fr);
gap: 12px;
padding: 16px;
}
.r6-settings .r6-model-lane > .r6-button {
.r6-settings .r6-model-lane-header > .r6-button {
justify-self: start;
}
.r6-settings .r6-model-owner {
padding: 20px 16px;
}
.r6-settings .r6-language-setting-row {
grid-template-columns: minmax(0, 1fr);
gap: 8px;
+24
View File
@@ -76,4 +76,28 @@ describe("raw map projection", () => {
history: [],
});
});
it("uses a turn identity while retaining the parent raw id", () => {
const turn = {
...record,
raw_id: "raw-batch",
turn_id: "raw-turn:batch:1",
turn_index: 1,
turn_count: 2,
content: "[user]: second question\n[assistant]: second answer",
messages: [
{ role: "user", content: "second question" },
{ role: "assistant", content: "second answer" },
],
};
expect(
rawLibraryPage({ items: [turn], total: 2, offset: 0, limit: 20 }),
).toMatchObject({
items: [{ memory_id: "raw-turn:batch:1", content: turn.content }],
source_ids_by_memory: { "raw-turn:batch:1": ["cursor"] },
});
expect(rawDetail(turn).memory.memory_id).toBe("raw-turn:batch:1");
expect(turn.raw_id).toBe("raw-batch");
});
});
+9 -4
View File
@@ -9,6 +9,10 @@ import type {
export type RawMapRecord = SdkRawRecord | SdkRawContextItem;
export function rawRecordId(record: RawMapRecord): string {
return record.turn_id || record.raw_id;
}
const EARLIEST_PLAUSIBLE = Date.UTC(2000, 0, 1);
const LATEST_PLAUSIBLE = () => Date.now() + 86_400_000;
@@ -43,7 +47,7 @@ function sourceOf(record: RawMapRecord): string {
export function rawMapMemory(record: RawMapRecord): GovernedMemory {
const created = rawRecordTime(record) ?? "";
return {
memory_id: record.raw_id,
memory_id: rawRecordId(record),
user_id: "user_id" in record ? record.user_id : "local",
kind: "chat",
status: "confirmed",
@@ -75,17 +79,18 @@ function pageFor(
{ at: string; basis: "conversation" | "import" }
> = {};
for (const record of records) {
sourceIds[record.raw_id] = [sourceOf(record)];
const recordId = rawRecordId(record);
sourceIds[recordId] = [sourceOf(record)];
const conversationAt = isoTime(record.memory_at);
const importedAt =
"gmt_created" in record ? isoTime(record.gmt_created) : "";
if (conversationAt)
sourceTime[record.raw_id] = {
sourceTime[recordId] = {
at: conversationAt,
basis: "conversation",
};
else if (importedAt)
sourceTime[record.raw_id] = { at: importedAt, basis: "import" };
sourceTime[recordId] = { at: importedAt, basis: "import" };
}
return {
items: records.map(rawMapMemory),
-1
View File
@@ -97,7 +97,6 @@
border-color: transparent;
}
.selected-source-filter .om-option-menu-button::after {
top: 18px;
right: 4px;
}
.selected-source-filter details {
+1 -1
View File
@@ -283,7 +283,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.13` is the current stable SDK dependency; the release
- `hy-memory==2.0.14` 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).
+1 -1
View File
@@ -1,6 +1,6 @@
# MemHub 安装与发行
MemHub `v1.0.6`(build 40)的发行目标如下:
MemHub `v1.0.6`(build 41)的发行目标如下:
| 平台 | 发行物 | 构建与验收 |
| --- | --- | --- |
+6 -5
View File
@@ -10,9 +10,10 @@
新增受限标量标签,仍应按私人数据处理,不能直接对外发送。`debug` 会让 HY 的
`hy_memory.log` 记录 DEBUG 行,其中可能包含完整 prompt/response 和获授权的对话正文。
排查时只通过 App 的“导出日志”提供文件:导出过程会按安全白名单重新生成诊断事件,不会打包
原始日志。不要直接发送 `diagnostic-*.jsonl`、整个 `~/.one-memory` 数据目录或 debug 级别的
`hy_memory.log`;后者如确需分享,必须由开发人员另行确认并脱敏。
App 的“导出日志”会同时打包安全投影和 `~/.one-memory/logs/` 下的完整运行日志,供可信开发人员
还原本机故障。原始日志可能包含会话正文和本机路径,发送前必须确认接收方;不要公开分享,也不要
发送整个 `~/.one-memory` 数据目录。SDK 的 debug 级 `hy_memory.log` 不在该 ZIP 中;如确需分享,
必须由开发人员另行确认并脱敏。只需要脱敏事件时使用 `memhub logs export`。
## 1. Log level 设什么
@@ -41,8 +42,8 @@ debug 可能把完整模型 prompt/response 写入本机调试日志,不得长
## 2. 日志在哪里
App 内推荐直接使用“运行诊断 → 导出日志”。开发人员远程指导时,优先使用
`memhub logs`,它会再次过滤不安全字段。原始结构化日志只用于本机现场查看,不得作为附件发送。
App 内推荐直接使用“运行诊断 → 导出日志”,它会生成包含完整运行日志的 ZIP。开发人员远程指导
且不需要原始日志时,优先使用 `memhub logs`,它会过滤不安全字段。不要手工打包整个数据目录。
Core 日志默认位于:
```text
+10 -5
View File
@@ -45,13 +45,18 @@ host-bridge、desktop 和 cli。单文件 4 MiB,保留 3 份轮转;额外清
读取器只枚举上述固定文件名的 regular file,不跟随符号链接,不接受任意路径。日常本地查询
遇到损坏行时跳过并计数,不让一行损坏遮蔽其余证据。
导出是独立的安全边界:它重新读取并校验全部候选事件,只把明确列入导出白名单的字段投影为
CLI 导出是独立的安全边界:它重新读取并校验全部候选事件,只把明确列入导出白名单的字段投影为
新的 `diagnostics/events.jsonl`;details 不只检查 key,还按字段约束 value 类型,并拒绝自由文本、
路径和凭证形态的字符串。绝不复制原始 `diagnostic-*.jsonl`、普通运行日志或崩溃报告。
导出包还可以包含固定 schema 的系统摘要和问题时间点,但不接受调用方传入的任意对象展开。
路径和凭证形态的字符串。CLI 不复制原始日志或崩溃报告。
Desktop 设置页的“导出日志”用于开发人员排查本机运行故障。除上述安全投影和状态摘要外,它还会
把 One Memory Home 的 `logs/` 目录中全部普通文件及轮转文件放入 ZIP 的 `logs/` 目录。它不跟随
符号链接、不递归读取其他目录,也不包含数据库、SDK 数据目录或系统崩溃报告。原始日志可能包含
会话正文、本机路径或其他私人信息,ZIP 的 README 必须明确提示只发送给可信的开发人员。
任何事件、marker、安全投影或日志目录读取失败时,导出必须 fail-closed:删除临时文件、不发布
诊断包,更不能回退为打包原始日志。发布使用已设好权限的临时文件原子创建最终目标,已存在的
目标不得覆盖,发布后也不存在再 chmod 失败留下伪成功文件的窗口。
诊断包。发布使用已设好权限的临时文件原子创建最终目标,已存在的目标不得覆盖,发布后也不存在
再 chmod 失败留下伪成功文件的窗口。
## 覆盖与节流
@@ -12,7 +12,7 @@ memory route. `user_id` is optional (default `local`) and must match
| Mode | SDK client | What happens to a conversation | What search reads |
|---|---|---|---|
| `lite` | `mode="lite"` | Stored as one raw turn with one embedding. No model call; no generation model needs to be configured. | Raw turns only (the SDK forces `L1_RAW`). |
| `lite` | `mode="lite"` | Stored in embedded L1 raw batches. No model call; no generation model needs to be configured. The product page projects each batch back into individual conversation turns. | Plain vector RAG over raw (the SDK forces `L1_RAW`), active and shadow, so history pro already processed stays findable. Every hit above the floor is returned by score; the turn view shows the turns containing the query, or else the hit's closest turn. |
| `pro` (default) | `mode="pro"` | Stored as raw, then a model extracts memories (System 1). The raw becomes SHADOW once extraction succeeds. | Extracted memories (facts, profile, intentions). |
| `ultra` | `mode="ultra"` | Same as pro, and new facts are also projected into the knowledge graph. Every night, evolve runs System 2 for every user. | Same as pro, plus the graph-backed lanes below. |
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "one-memory",
"version": "1.0.6",
"release": {
"build_number": 40,
"build_number": 41,
"macos_min_version": "13.0",
"app_update": "manual",
"runtime_manifest_schema": 1,
+5
View File
@@ -87,7 +87,12 @@ export interface ContextSearchResponse {
export interface SdkRawContextItem {
kind: "raw";
/** Parent SDK L1 raw storage batch. */
raw_id: string;
/** Stable product identity of one turn inside `raw_id`, when requested. */
turn_id?: string | null;
turn_index?: number | null;
turn_count?: number | null;
content: string;
source: string;
session_id: string;
+11
View File
@@ -675,7 +675,12 @@ export interface SdkUserProfileResponse {
}
export interface SdkRawRecord {
/** Parent SDK L1 raw storage batch. */
raw_id: string;
/** Stable product identity of one turn inside `raw_id`, when unit=turn. */
turn_id?: string | null;
turn_index?: number | null;
turn_count?: number | null;
content: string;
user_id: string;
agent_id: string;
@@ -703,6 +708,7 @@ export interface SdkRawPage {
total: number;
limit: number;
offset: number;
unit?: "raw" | "turn";
/** A time range was applied to this page in Core (the SDK list has none). */
time_filtered?: boolean;
}
@@ -1112,6 +1118,11 @@ export interface MemoryProcessingSummary {
llm_backoff_until?: string | null;
/** The provider failure behind the pause, as a stable code. */
llm_backoff_error?: string | null;
/** The durable write drain: `restarting` after an unexpected exit, until it
* runs again. Null before startup recovery started it. */
write_drain?: "running" | "restarting" | "stopped" | null;
/** Why the drain last exited unexpectedly, as a stable code. */
write_drain_error?: string | null;
/** The active memory pipeline and its graph/evolve status. */
pipeline?: MemoryPipelineStatus | null;
/** A running embedding switch or startup merge pauses mode mutations. */
@@ -9,6 +9,7 @@ export const applicationsChineseMessages = {
"apps.memories": "已沉淀记忆",
"apps.rawConversations": "原始对话",
"apps.rawSaving": "正在保存原始对话",
"apps.rawSavingCompact": "正在保存",
"apps.rawSavingStatus": "保存状态",
"apps.rawSavingDetail": "原始对话持续保存,不需要生成模型",
"apps.rawNoExcerpt": "这条对话没有可显示的用户原文",
@@ -17,6 +18,7 @@ export const applicationsChineseMessages = {
"apps.rawEmpty": "还没有保存的原始对话",
"apps.rawCheckedEmpty": "已核对,暂无可显示的原始对话更新",
"apps.memoryCheckedEmpty": "已核对,暂无可显示的记忆更新",
"apps.recentEmptyCompact": "暂无更新",
"apps.rawWaiting": "原始对话保存后会显示在这里",
"apps.checkedRecent": "已核对当前内容",
"apps.currentMode": "当前模式",
@@ -149,6 +151,7 @@ export const applicationsSpanishMessages: Record<
"apps.memories": "Memorias guardadas",
"apps.rawConversations": "Conversaciones originales",
"apps.rawSaving": "Guardando",
"apps.rawSavingCompact": "Guardando",
"apps.rawSavingStatus": "Estado de guardado",
"apps.rawSavingDetail":
"Las conversaciones originales se guardan sin un modelo generativo",
@@ -160,6 +163,7 @@ export const applicationsSpanishMessages: Record<
"Comprobado: no hay actualizaciones de conversaciones para mostrar",
"apps.memoryCheckedEmpty":
"Comprobado: no hay actualizaciones de memorias para mostrar",
"apps.recentEmptyCompact": "Sin novedades",
"apps.rawWaiting":
"Las conversaciones originales aparecerán aquí cuando se guarden",
"apps.checkedRecent": "Contenido actual comprobado",
@@ -275,9 +275,10 @@ describe("Mode catalog", () => {
"搜索沉淀记忆,并用知识图谱补充关联。",
"搜索沉淀后的记忆。",
"已经处理完的旧对话不会补做;原始对话全部保留。",
"已有记忆继续保留,但高级记忆在当前模式下不显示、不参与召回。",
"知识图谱",
"普通沉淀记忆仍可使用;深度记忆新增的更高层记忆和图谱数据继续保留,但在智能记忆下不显示、不参与召回。原始对话不会删除。",
"已有记忆会在后台加入知识图谱,期间仍可使用。",
"普通沉淀记忆仍可使用;更高层记忆和图谱数据继续保留,但不参与当前召回;每天自动整合会停止。",
"每天自动整合会停止,已经生成的数据不会删除。",
"记忆维护正在进行,完成后才能切换模式。",
"模式切换影响暂时无法读取,请重试。",
] as const;
+3 -1
View File
@@ -877,7 +877,7 @@ const baseEnglishMessages = {
"Keep syncing, processing and providing memories without opening the app.",
导出日志: "Export logs",
"把本机运行日志打包成一个文件,发给开发排查。":
"Package local diagnostic logs into a file to share with support.",
"Package all local logs. They may contain conversation text or local paths; share only with trusted developers.",
"确定退出 One Memory 界面吗?": "Quit the MemHub window?",
"后台服务会继续同步、整理和提供记忆。":
"The background service will continue syncing, processing and providing memories.",
@@ -1345,6 +1345,7 @@ const baseEnglishMessages = {
"apps.memories": "Saved memories",
"apps.rawConversations": "Original conversations",
"apps.rawSaving": "Saving",
"apps.rawSavingCompact": "Saving",
"apps.rawSavingStatus": "Saving status",
"apps.rawSavingDetail":
"Original conversations are saved without a generation model",
@@ -1355,6 +1356,7 @@ const baseEnglishMessages = {
"apps.rawCheckedEmpty":
"Checked: no original-conversation updates to display",
"apps.memoryCheckedEmpty": "Checked: no memory updates to display",
"apps.recentEmptyCompact": "No updates",
"apps.rawWaiting": "Original conversations will appear here when saved",
"apps.checkedRecent": "Current content checked",
"apps.currentMode": "Current mode",
@@ -24,6 +24,8 @@ const ENGLISH_EQUIVALENT_ALLOWED = new Set<MessageKey>([
const keys = Object.keys(chineseMessages) as MessageKey[];
const rawSavingStatusKey = "apps.rawSavingStatus" satisfies MessageKey;
const rawSavingCompactKey = "apps.rawSavingCompact" satisfies MessageKey;
const recentEmptyCompactKey = "apps.recentEmptyCompact" satisfies MessageKey;
const rawSavingStatusTranslations = {
en: "Saving status",
@@ -39,6 +41,23 @@ const rawSavingStatusTranslations = {
id: "Status penyimpanan",
} as const satisfies Record<(typeof SUPPORTED_LOCALES)[number], string>;
const compactHomeTranslations = {
en: ["Saving", "No updates"],
"zh-Hans": ["正在保存", "暂无更新"],
"zh-Hant": ["儲存中", "暫無更新"],
ja: ["保存中", "更新なし"],
es: ["Guardando", "Sin novedades"],
de: ["Wird gespeichert", "Keine Updates"],
pt: ["Salvando", "Sem atualizações"],
fr: ["Enregistrement", "Aucune mise à jour"],
ko: ["저장 중", "업데이트 없음"],
ru: ["Сохранение", "Нет обновлений"],
id: ["Menyimpan", "Belum ada pembaruan"],
} as const satisfies Record<
(typeof SUPPORTED_LOCALES)[number],
readonly [string, string]
>;
describe.each(SUPPORTED_LOCALES)("%s catalog", (locale) => {
const catalog = guiMessages[locale];
@@ -100,6 +119,14 @@ describe("applications catalog", () => {
rawSavingStatusTranslations[locale],
);
});
it("keeps large home metric copy compact in all 11 locales", () => {
for (const locale of SUPPORTED_LOCALES) {
const [saving, empty] = compactHomeTranslations[locale];
expect(guiMessages[locale][rawSavingCompactKey]).toBe(saving);
expect(guiMessages[locale][recentEmptyCompactKey]).toBe(empty);
}
});
});
describe("embedding switch catalog", () => {
+12 -1
View File
@@ -47,7 +47,7 @@ const modeChineseMessages = {
"以前生成的高级记忆会继续保留,但在 lite 下不会显示或参与记忆召回。":
"高级记忆会保留,但在 lite 下不显示、不参与召回。",
"每天自动整合会停止,已经生成的数据不会删除。":
"自动整合会停止,已有数据保留。",
"每天自动整合会停止;已生成的整合结果继续保留。",
"已有高级记忆会在后台加入知识图谱,期间可以继续使用 MemHub。":
"已有高级记忆将在后台加入知识图谱。",
"知识图谱仍在补建,不会重复启动。": "知识图谱仍在补建,不会重复启动。",
@@ -97,6 +97,7 @@ const modeChineseMessages = {
模型调用与费用: "模型调用与费用",
搜索范围: "搜索范围",
已有数据: "已有数据",
知识图谱: "知识图谱",
切换后: "切换后",
"之后只保存原始对话,不再生成新记忆。":
"之后只保存原始对话,不再生成新记忆。",
@@ -295,6 +296,7 @@ export const modeEnglishMessages: ModeMessages = {
模型调用与费用: "Model calls and cost",
搜索范围: "Search scope",
已有数据: "Existing data",
知识图谱: "Knowledge graph",
切换后: "After switching",
"之后只保存原始对话,不再生成新记忆。":
"New conversations are saved as original text only; no new memories are created.",
@@ -498,6 +500,7 @@ export const modeSpanishMessages: ModeMessages = {
模型调用与费用: "Llamadas al modelo y coste",
搜索范围: "Ámbito de búsqueda",
已有数据: "Datos existentes",
知识图谱: "Grafo de conocimiento",
切换后: "Después del cambio",
"之后只保存原始对话,不再生成新记忆。":
"Las conversaciones nuevas se guardan solo como texto original; no se crean memorias nuevas.",
@@ -693,6 +696,7 @@ export const modeTraditionalChineseMessages: ModeMessages = {
模型调用与费用: "模型呼叫與費用",
搜索范围: "搜尋範圍",
已有数据: "現有資料",
知识图谱: "知識圖譜",
切换后: "切換後",
"之后只保存原始对话,不再生成新记忆。":
"之後只儲存原始對話,不再產生新記憶。",
@@ -843,6 +847,7 @@ export const modeJapaneseMessages: ModeMessages = {
模型调用与费用: "モデル呼び出しと料金",
搜索范围: "検索範囲",
已有数据: "既存データ",
知识图谱: "ナレッジグラフ",
切换后: "切り替え後",
"之后只保存原始对话,不再生成新记忆。":
"以降は元の会話のみを保存し、新しい記憶は生成しません。",
@@ -1043,6 +1048,7 @@ export const modeGermanMessages: ModeMessages = {
模型调用与费用: "Modellaufrufe und Kosten",
搜索范围: "Suchbereich",
已有数据: "Vorhandene Daten",
知识图谱: "Wissensgraph",
切换后: "Nach dem Wechsel",
"之后只保存原始对话,不再生成新记忆。":
"Neue Unterhaltungen werden nur noch im Original gespeichert; es werden keine neuen Erinnerungen erstellt.",
@@ -1246,6 +1252,7 @@ export const modePortugueseMessages: ModeMessages = {
模型调用与费用: "Chamadas ao modelo e custos",
搜索范围: "Âmbito da pesquisa",
已有数据: "Dados existentes",
知识图谱: "Grafo de conhecimento",
切换后: "Após a mudança",
"之后只保存原始对话,不再生成新记忆。":
"As novas conversas passam a ser guardadas apenas como texto original; não são criadas novas memórias.",
@@ -1451,6 +1458,7 @@ export const modeFrenchMessages: ModeMessages = {
模型调用与费用: "Appels au modèle et coût",
搜索范围: "Périmètre de recherche",
已有数据: "Données existantes",
知识图谱: "Graphe de connaissances",
切换后: "Après le changement",
"之后只保存原始对话,不再生成新记忆。":
"Les nouvelles conversations sont uniquement enregistrées dans leur forme originale ; aucune nouvelle mémoire n’est créée.",
@@ -1651,6 +1659,7 @@ export const modeKoreanMessages: ModeMessages = {
模型调用与费用: "모델 호출 및 비용",
搜索范围: "검색 범위",
已有数据: "기존 데이터",
知识图谱: "지식 그래프",
切换后: "전환 후",
"之后只保存原始对话,不再生成新记忆。":
"이후 새 대화는 원본만 저장되며 새 메모리는 생성되지 않습니다.",
@@ -1850,6 +1859,7 @@ export const modeRussianMessages: ModeMessages = {
模型调用与费用: "Вызовы модели и расходы",
搜索范围: "Область поиска",
已有数据: "Существующие данные",
知识图谱: "Граф знаний",
切换后: "После переключения",
"之后只保存原始对话,不再生成新记忆。":
"Новые диалоги будут сохраняться только в исходном виде; новые воспоминания создаваться не будут.",
@@ -2052,6 +2062,7 @@ export const modeIndonesianMessages: ModeMessages = {
模型调用与费用: "Panggilan model dan biaya",
搜索范围: "Cakupan pencarian",
已有数据: "Data yang ada",
知识图谱: "Graf pengetahuan",
切换后: "Setelah beralih",
"之后只保存原始对话,不再生成新记忆。":
"Percakapan baru hanya disimpan sebagai teks asli; tidak ada memori baru yang dibuat.",
@@ -251,7 +251,7 @@ export const settingsSpanishMessages = {
"Sincroniza, organiza y ofrece memorias sin abrir la interfaz.",
导出日志: "Exportar registros",
"把本机运行日志打包成一个文件,发给开发排查。":
"Empaqueta los registros locales en un archivo para su diagnóstico.",
"Empaqueta todos los registros locales. Pueden contener conversaciones o rutas privadas; compártelos solo con personal de desarrollo de confianza.",
"确定退出 One Memory 界面吗?": "¿Cerrar la interfaz de MemHub?",
"后台服务会继续同步、整理和提供记忆。":
"El servicio en segundo plano seguirá sincronizando, organizando y ofreciendo memorias.",
@@ -362,6 +362,8 @@ export const settingsSpanishMessages = {
const chineseOverrides: Partial<
Record<keyof typeof settingsSpanishMessages, string>
> = {
"把本机运行日志打包成一个文件,发给开发排查。":
"打包完整本机日志,可能包含会话正文和本机路径;只发送给可信的开发人员。",
"确定退出 One Memory 界面吗?": "确定退出 MemHub 界面吗?",
appSettingsTitle: "{name} 应用设置",
appMemoryScope: "让 {name} 查询已授权范围内的记忆。",
@@ -1065,7 +1065,7 @@ export const germanMessages: Record<MessageKey, string> = {
"Erinnerungen weiter synchronisieren, verarbeiten und bereitstellen, ohne die App zu öffnen.",
导出日志: "Protokolle exportieren",
"把本机运行日志打包成一个文件,发给开发排查。":
"Packen Sie lokale Diagnoseprotokolle in eine Datei, um sie an den Support zu senden.",
"Packt alle lokalen Protokolle. Sie können Gesprächsinhalte oder lokale Pfade enthalten; nur mit vertrauenswürdigen Entwicklern teilen.",
"确定退出 One Memory 界面吗?": "MemHub-Fenster beenden?",
"后台服务会继续同步、整理和提供记忆。":
"Der Hintergrunddienst synchronisiert, verarbeitet und liefert Erinnerungen weiter.",
@@ -1544,6 +1544,7 @@ export const germanMessages: Record<MessageKey, string> = {
"apps.memories": "Gespeicherte Erinnerungen",
"apps.rawConversations": "Originalunterhaltungen",
"apps.rawSaving": "Wird gespeichert",
"apps.rawSavingCompact": "Wird gespeichert",
"apps.rawSavingStatus": "Speicherstatus",
"apps.rawSavingDetail":
"Originalunterhaltungen werden ohne generatives Modell gespeichert",
@@ -1555,6 +1556,7 @@ export const germanMessages: Record<MessageKey, string> = {
"Geprüft: keine anzeigbaren Aktualisierungen der Originalunterhaltungen",
"apps.memoryCheckedEmpty":
"Geprüft: keine anzeigbaren Erinnerungsaktualisierungen",
"apps.recentEmptyCompact": "Keine Updates",
"apps.rawWaiting": "Gespeicherte Originalunterhaltungen erscheinen hier",
"apps.checkedRecent": "Aktueller Inhalt geprüft",
"apps.currentMode": "Aktueller Modus",
@@ -1078,7 +1078,7 @@ export const frenchMessages: Record<MessageKey, string> = {
"Continuez à synchroniser, traiter et fournir les mémoires sans ouvrir l’application.",
导出日志: "Exporter les journaux",
"把本机运行日志打包成一个文件,发给开发排查。":
"Regroupez les journaux de diagnostic locaux dans un fichier à transmettre à l’assistance.",
"Regroupe tous les journaux locaux. Ils peuvent contenir des conversations ou des chemins locaux ; partagez-les uniquement avec des développeurs de confiance.",
"确定退出 One Memory 界面吗?": "Quitter la fenêtre MemHub ?",
"后台服务会继续同步、整理和提供记忆。":
"Le service en arrière-plan continuera de synchroniser, traiter et fournir les mémoires.",
@@ -1565,6 +1565,7 @@ export const frenchMessages: Record<MessageKey, string> = {
"apps.memories": "Mémoires enregistrées",
"apps.rawConversations": "Conversations d’origine",
"apps.rawSaving": "Enregistrement",
"apps.rawSavingCompact": "Enregistrement",
"apps.rawSavingStatus": "État de l’enregistrement",
"apps.rawSavingDetail":
"Les conversations d’origine sont enregistrées sans modèle génératif",
@@ -1576,6 +1577,7 @@ export const frenchMessages: Record<MessageKey, string> = {
"Vérifié : aucune mise à jour de conversation à afficher",
"apps.memoryCheckedEmpty":
"Vérifié : aucune mise à jour de mémoire à afficher",
"apps.recentEmptyCompact": "Aucune mise à jour",
"apps.rawWaiting":
"Les conversations d’origine enregistrées apparaîtront ici",
"apps.checkedRecent": "Contenu actuel vérifié",
@@ -1048,7 +1048,7 @@ export const indonesianMessages: Record<MessageKey, string> = {
"Terus sinkronkan, proses, dan sediakan memori tanpa membuka aplikasi.",
导出日志: "Ekspor log",
"把本机运行日志打包成一个文件,发给开发排查。":
"Kemas log diagnostik lokal menjadi satu file untuk dibagikan ke tim dukungan.",
"Kemas semua log lokal. Log dapat memuat isi percakapan atau jalur lokal; bagikan hanya kepada pengembang tepercaya.",
"确定退出 One Memory 界面吗?": "Tutup jendela MemHub?",
"后台服务会继续同步、整理和提供记忆。":
"Layanan latar belakang akan terus menyinkronkan, memproses, dan menyediakan memori.",
@@ -1519,6 +1519,7 @@ export const indonesianMessages: Record<MessageKey, string> = {
"apps.memories": "Memori tersimpan",
"apps.rawConversations": "Percakapan asli",
"apps.rawSaving": "Menyimpan",
"apps.rawSavingCompact": "Menyimpan",
"apps.rawSavingStatus": "Status penyimpanan",
"apps.rawSavingDetail": "Percakapan asli disimpan tanpa model generatif",
"apps.rawNoExcerpt": "Tidak ada kutipan pengguna yang dapat ditampilkan",
@@ -1529,6 +1530,7 @@ export const indonesianMessages: Record<MessageKey, string> = {
"Diperiksa: tidak ada pembaruan percakapan untuk ditampilkan",
"apps.memoryCheckedEmpty":
"Diperiksa: tidak ada pembaruan memori untuk ditampilkan",
"apps.recentEmptyCompact": "Belum ada pembaruan",
"apps.rawWaiting": "Percakapan asli yang disimpan akan muncul di sini",
"apps.checkedRecent": "Konten saat ini telah diperiksa",
"apps.currentMode": "Mode saat ini",
@@ -1045,7 +1045,7 @@ export const japaneseMessages: Record<MessageKey, string> = {
"アプリを開かなくても、記憶の同期、処理、提供を続けます。",
导出日志: "ログを書き出す",
"把本机运行日志打包成一个文件,发给开发排查。":
"ローカルの診断ログを 1 つのファイルにまとめ、サポートに共有できます。",
"ローカルログをすべてまとめます。会話本文やローカルパスが含まれる場合があるため、信頼できる開発者にのみ共有してください。",
"确定退出 One Memory 界面吗?": "MemHub のウインドウを終了しますか?",
"后台服务会继续同步、整理和提供记忆。":
"バックグラウンドサービスは記憶の同期、処理、提供を続けます。",
@@ -1516,6 +1516,7 @@ export const japaneseMessages: Record<MessageKey, string> = {
"apps.memories": "保存済みの記憶",
"apps.rawConversations": "元の会話",
"apps.rawSaving": "保存中",
"apps.rawSavingCompact": "保存中",
"apps.rawSavingStatus": "保存状態",
"apps.rawSavingDetail": "生成モデルを使わず元の会話を保存します",
"apps.rawNoExcerpt": "表示できるユーザーの原文がありません",
@@ -1524,6 +1525,7 @@ export const japaneseMessages: Record<MessageKey, string> = {
"apps.rawEmpty": "保存済みの元の会話はまだありません",
"apps.rawCheckedEmpty": "確認済み:表示できる元の会話の更新はありません",
"apps.memoryCheckedEmpty": "確認済み:表示できる記憶の更新はありません",
"apps.recentEmptyCompact": "更新なし",
"apps.rawWaiting": "保存された元の会話がここに表示されます",
"apps.checkedRecent": "現在の内容を確認済み",
"apps.currentMode": "現在のモード",
@@ -1021,7 +1021,7 @@ export const koreanMessages: Record<MessageKey, string> = {
"앱을 열지 않아도 기억을 계속 동기화, 정리하고 제공합니다.",
导出日志: "로그 내보내기",
"把本机运行日志打包成一个文件,发给开发排查。":
"로컬 진단 로그를 파일 하나로 묶어 지원팀에 보냅니다.",
"모든 로컬 로그를 묶습니다. 대화 내용이나 로컬 경로가 포함될 수 있으므로 신뢰할 수 있는 개발자에게만 공유하세요.",
"确定退出 One Memory 界面吗?": "MemHub 창을 종료하시겠습니까?",
"后台服务会继续同步、整理和提供记忆。":
"백그라운드 서비스는 계속 기억을 동기화, 정리하고 제공합니다.",
@@ -1480,6 +1480,7 @@ export const koreanMessages: Record<MessageKey, string> = {
"apps.memories": "저장된 기억",
"apps.rawConversations": "원본 대화",
"apps.rawSaving": "저장 중",
"apps.rawSavingCompact": "저장 중",
"apps.rawSavingStatus": "저장 상태",
"apps.rawSavingDetail": "생성 모델 없이 원본 대화를 저장합니다",
"apps.rawNoExcerpt": "표시할 수 있는 사용자 원문이 없습니다",
@@ -1488,6 +1489,7 @@ export const koreanMessages: Record<MessageKey, string> = {
"apps.rawEmpty": "저장된 원본 대화가 아직 없습니다",
"apps.rawCheckedEmpty": "확인됨: 표시할 원본 대화 업데이트가 없습니다",
"apps.memoryCheckedEmpty": "확인됨: 표시할 메모리 업데이트가 없습니다",
"apps.recentEmptyCompact": "업데이트 없음",
"apps.rawWaiting": "저장된 원본 대화가 여기에 표시됩니다",
"apps.checkedRecent": "현재 내용 확인됨",
"apps.currentMode": "현재 모드",
@@ -1069,7 +1069,7 @@ export const portugueseMessages: Record<MessageKey, string> = {
"Continue sincronizando, processando e fornecendo memórias sem abrir o app.",
导出日志: "Exportar logs",
"把本机运行日志打包成一个文件,发给开发排查。":
"Empacote os logs de diagnóstico locais em um arquivo para compartilhar com o suporte.",
"Empacota todos os logs locais. Eles podem conter conversas ou caminhos locais; compartilhe apenas com desenvolvedores de confiança.",
"确定退出 One Memory 界面吗?": "Fechar a janela do MemHub?",
"后台服务会继续同步、整理和提供记忆。":
"O serviço em segundo plano continuará sincronizando, processando e fornecendo memórias.",
@@ -1541,6 +1541,7 @@ export const portugueseMessages: Record<MessageKey, string> = {
"apps.memories": "Memórias salvas",
"apps.rawConversations": "Conversas originais",
"apps.rawSaving": "Salvando",
"apps.rawSavingCompact": "Salvando",
"apps.rawSavingStatus": "Status de salvamento",
"apps.rawSavingDetail":
"As conversas originais são salvas sem um modelo generativo",
@@ -1552,6 +1553,7 @@ export const portugueseMessages: Record<MessageKey, string> = {
"Verificado: não há atualizações de conversas para mostrar",
"apps.memoryCheckedEmpty":
"Verificado: não há atualizações de memórias para mostrar",
"apps.recentEmptyCompact": "Sem atualizações",
"apps.rawWaiting": "As conversas originais salvas aparecerão aqui",
"apps.checkedRecent": "Conteúdo atual verificado",
"apps.currentMode": "Modo atual",
@@ -1053,7 +1053,7 @@ export const russianMessages: Record<MessageKey, string> = {
"Синхронизация, обработка и предоставление памяти без открытия приложения.",
导出日志: "Экспорт журналов",
"把本机运行日志打包成一个文件,发给开发排查。":
"Упакуйте локальные журналы диагностики в файл, чтобы отправить в поддержку.",
"Упаковывает все локальные журналы. Они могут содержать текст диалогов или локальные пути; отправляйте их только доверенным разработчикам.",
"确定退出 One Memory 界面吗?": "Закрыть окно MemHub?",
"后台服务会继续同步、整理和提供记忆。":
"Фоновая служба продолжит синхронизацию, обработку и предоставление памяти.",
@@ -1524,6 +1524,7 @@ export const russianMessages: Record<MessageKey, string> = {
"apps.memories": "Сохранённые записи памяти",
"apps.rawConversations": "Исходные разговоры",
"apps.rawSaving": "Сохранение",
"apps.rawSavingCompact": "Сохранение",
"apps.rawSavingStatus": "Статус сохранения",
"apps.rawSavingDetail":
"Исходные разговоры сохраняются без генеративной модели",
@@ -1534,6 +1535,7 @@ export const russianMessages: Record<MessageKey, string> = {
"apps.rawCheckedEmpty":
"Проверено: нет обновлений исходных диалогов для показа",
"apps.memoryCheckedEmpty": "Проверено: нет обновлений памяти для показа",
"apps.recentEmptyCompact": "Нет обновлений",
"apps.rawWaiting": "Сохранённые исходные диалоги появятся здесь",
"apps.checkedRecent": "Текущее содержимое проверено",
"apps.currentMode": "Текущий режим",
@@ -951,7 +951,7 @@ export const traditionalChineseMessages: Record<MessageKey, string> = {
"不需開啟視窗,也能持續同步、整理和提供記憶。",
导出日志: "匯出日誌",
"把本机运行日志打包成一个文件,发给开发排查。":
"把本機執行日誌打包成一個檔案,提供給開發人員排查。",
"打包完整本機日誌,可能包含對話正文和本機路徑;只傳送給可信的開發人員。",
"确定退出 One Memory 界面吗?": "要關閉 MemHub 視窗嗎?",
"后台服务会继续同步、整理和提供记忆。":
"背景服務會繼續同步、整理和提供記憶。",
@@ -1388,6 +1388,7 @@ export const traditionalChineseMessages: Record<MessageKey, string> = {
"apps.memories": "已建立的記憶",
"apps.rawConversations": "原始對話",
"apps.rawSaving": "儲存中",
"apps.rawSavingCompact": "儲存中",
"apps.rawSavingStatus": "儲存狀態",
"apps.rawSavingDetail": "原始對話會持續儲存,不需要生成模型",
"apps.rawNoExcerpt": "這段對話沒有可顯示的使用者原文",
@@ -1396,6 +1397,7 @@ export const traditionalChineseMessages: Record<MessageKey, string> = {
"apps.rawEmpty": "還沒有儲存的原始對話",
"apps.rawCheckedEmpty": "已核對,暫無可顯示的原始對話更新",
"apps.memoryCheckedEmpty": "已核對,暫無可顯示的記憶更新",
"apps.recentEmptyCompact": "暫無更新",
"apps.rawWaiting": "原始對話儲存後會顯示在這裡",
"apps.checkedRecent": "已核對目前內容",
"apps.currentMode": "目前模式",
@@ -32,7 +32,31 @@ describe("memory mode plan", () => {
expect(parseMemoryModePlan({ ...valid, blockers: ["unknown"] })).toBeNull();
});
it("isolates the old-Core matrix in one helper", () => {
it.each([
["lite", "pro", ["earlier_raw_not_extracted", "lite_search_stops"]],
[
"lite",
"ultra",
[
"earlier_raw_not_extracted",
"lite_search_stops",
"existing_memories_added_to_graph",
],
],
["pro", "lite", ["higher_mode_memories_hidden"]],
["pro", "ultra", ["existing_memories_added_to_graph"]],
["ultra", "lite", ["higher_mode_memories_hidden", "evolve_stopped"]],
["ultra", "pro", ["higher_mode_memories_hidden", "evolve_stopped"]],
] as const)(
"keeps the old-Core %s to %s effects aligned with the plan route",
(current, target, effects) => {
expect(
legacyMemoryModePlan(current, target, { hasActiveModel: true }).effects,
).toEqual(effects);
},
);
it("keeps the old-Core model blocker and target capabilities", () => {
expect(
legacyMemoryModePlan("lite", "ultra", { hasActiveModel: false }),
).toMatchObject({
@@ -42,10 +66,6 @@ describe("memory mode plan", () => {
graph_enabled: true,
},
blockers: ["active_model_required"],
effects: expect.arrayContaining([
"earlier_raw_not_extracted",
"existing_memories_added_to_graph",
]),
});
});
});
@@ -106,12 +106,15 @@ export function legacyMemoryModePlan(
options: { hasActiveModel: boolean },
): MemoryModePlan {
const effects: MemoryModeChange["effects"] = [];
if (target === "lite") {
effects.push("higher_mode_memories_hidden");
if (current === "ultra") effects.push("evolve_stopped");
} else if (current === "lite") {
const rank: Record<MemoryPipelineMode, number> = {
lite: 0,
pro: 1,
ultra: 2,
};
if (current !== target && current === "lite")
effects.push("earlier_raw_not_extracted", "lite_search_stops");
}
if (rank[target] < rank[current]) effects.push("higher_mode_memories_hidden");
if (current !== target && current === "ultra") effects.push("evolve_stopped");
if (target === "ultra" && current !== "ultra")
effects.push("existing_memories_added_to_graph");
return {
@@ -735,6 +735,76 @@ describe("diagnostic log", () => {
expect(all).not.toContain("/Users/");
});
it("optionally includes every regular file from the local logs directory", async () => {
const dataDir = await root();
const outputDir = await root();
const logs = path.join(dataDir, "logs");
await mkdir(logs, { recursive: true });
await writeFile(
path.join(logs, "diagnostic-core.jsonl"),
`${JSON.stringify({
schema_version: 1,
timestamp: "2026-10-05T01:00:00.000Z",
level: "error",
service: "core",
component: "core.database",
event: "database.error",
outcome: "failed",
session_id: "11111111-1111-4111-8111-111111111111",
error_code: "database_locked",
})}\n`,
);
await writeFile(
path.join(logs, "core.log"),
"raw private conversation and full local path\n",
);
await writeFile(path.join(logs, "service-error.log.1"), "rotated error\n");
await writeFile(path.join(logs, ".hidden log"), "hidden diagnostic\n");
await writeFile(path.join(logs, "服务 日志.log"), "unicode diagnostic\n");
await writeFile(path.join(logs, "empty.log"), "");
await writeFile(
path.join(logs, "large.log"),
Buffer.alloc(256 * 1024, 120),
);
const outside = path.join(await root(), "outside.log");
await writeFile(outside, "must not be followed\n");
await symlink(outside, path.join(logs, "linked.log"));
const target = path.join(outputDir, "MemHub.zip");
const result = await exportDiagnosticZip({
dataDir,
target,
productVersion: "3800",
includeRawLogs: true,
});
expect(result).toEqual({ filename: "MemHub.zip", file_count: 10 });
const files = unzipSync(await readFile(target));
expect(Object.keys(files).sort()).toEqual([
"README.txt",
"diagnostics/events.jsonl",
"logs/.hidden log",
"logs/core.log",
"logs/diagnostic-core.jsonl",
"logs/empty.log",
"logs/large.log",
"logs/service-error.log.1",
"logs/服务 日志.log",
"summary.json",
]);
expect(strFromU8(files["logs/core.log"] ?? new Uint8Array())).toContain(
"raw private conversation",
);
expect(strFromU8(files["README.txt"] ?? new Uint8Array())).toContain(
"原始日志可能包含会话正文",
);
expect(files["logs/linked.log"]).toBeUndefined();
expect(files["logs/empty.log"]).toHaveLength(0);
expect(files["logs/large.log"]).toHaveLength(256 * 1024);
if (process.platform !== "win32")
expect((await lstat(target)).mode & 0o777).toBe(0o600);
});
it("fails closed without publishing a ZIP when a diagnostic line is invalid", async () => {
const dataDir = await root();
await mkdir(path.join(dataDir, "logs"), { recursive: true });
@@ -1,8 +1,10 @@
import { randomUUID } from "node:crypto";
import { once } from "node:events";
import {
appendFileSync,
chmodSync,
constants,
createWriteStream,
lstatSync,
mkdirSync,
renameSync,
@@ -12,6 +14,7 @@ import {
import {
appendFile,
chmod,
type FileHandle,
link,
lstat,
mkdir,
@@ -25,7 +28,7 @@ import {
} from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { strToU8, zipSync } from "fflate";
import { strToU8, Zip, ZipPassThrough, zipSync } from "fflate";
export const DIAGNOSTIC_SCHEMA_VERSION = 1 as const;
export const DIAGNOSTIC_MAX_FILE_BYTES = 4 * 1024 * 1024;
@@ -627,6 +630,7 @@ export async function exportDiagnosticZip(options: {
now?: Date;
markers?: DiagnosticMarker[];
summary?: Record<string, unknown>;
includeRawLogs?: boolean;
}): Promise<{ filename: string; file_count: number }> {
const now = options.now ?? new Date();
const home = options.home ?? os.homedir();
@@ -672,7 +676,14 @@ export async function exportDiagnosticZip(options: {
"MemHub diagnostics",
"",
"包含:最近 7 天通过安全白名单重新投影的诊断事件,以及不含正文的状态摘要。",
"不包含:原始日志、崩溃报告、对话原文、记忆内容、API Key、token 或宿主凭证。",
...(options.includeRawLogs
? [
"同时包含:One Memory Home 的 logs/ 目录中全部普通日志文件及轮转文件。",
"注意:原始日志可能包含会话正文、本机路径或其他私人信息,只应发送给可信的开发人员。",
]
: [
"不包含:原始日志、崩溃报告、对话原文、记忆内容、API Key、token 或宿主凭证。",
]),
...(markers.length
? [
"",
@@ -689,20 +700,30 @@ export async function exportDiagnosticZip(options: {
const directory = path.dirname(options.target);
await mkdir(directory, { recursive: true });
const temporary = path.join(directory, `.memhub-logs-${randomUUID()}.tmp`);
const rawLogs = options.includeRawLogs
? await openRawLogSnapshots(options.dataDir)
: [];
try {
await writeFile(temporary, zipSync(archive, { level: 6 }), {
mode: 0o600,
flag: "wx",
});
if (options.includeRawLogs)
await writeStreamingDiagnosticZip(temporary, archive, rawLogs);
else
await writeFile(temporary, zipSync(archive, { level: 6 }), {
mode: 0o600,
flag: "wx",
});
await chmod(temporary, 0o600);
await publishTemporaryFile(temporary, options.target);
} catch (error) {
await rm(temporary, { force: true }).catch(() => undefined);
throw error;
} finally {
await Promise.all(
rawLogs.map((file) => file.handle.close().catch(() => undefined)),
);
}
return {
filename: path.basename(options.target),
file_count: Object.keys(archive).length,
file_count: Object.keys(archive).length + rawLogs.length,
};
}
@@ -902,6 +923,151 @@ async function readRegularFileNoFollow(
}
}
interface RawLogSnapshot {
name: string;
handle: FileHandle;
size: number;
}
async function openRawLogSnapshots(dataDir: string): Promise<RawLogSnapshot[]> {
const directory = diagnosticDirectory(dataDir);
let directoryInfo: Awaited<ReturnType<typeof lstat>>;
try {
directoryInfo = await lstat(directory);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
throw error;
}
if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink())
fail("diagnostic_directory_unsafe");
const entries = await readdir(directory, { withFileTypes: true });
const snapshots: RawLogSnapshot[] = [];
try {
for (const entry of entries) {
if (entry.isSymbolicLink()) continue;
if (
entry.name === "." ||
entry.name === ".." ||
[...entry.name].some((character) => {
const code = character.charCodeAt(0);
return (
character === "/" || character === "\\" || code < 32 || code === 127
);
})
)
fail("diagnostic_log_name_unsafe");
const target = path.join(directory, entry.name);
const before = await lstat(target).catch(() => null);
if (!before?.isFile() || before.isSymbolicLink()) continue;
const handle = await open(
target,
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
);
let retained = false;
try {
const opened = await handle.stat();
if (
!opened.isFile() ||
opened.dev !== before.dev ||
opened.ino !== before.ino
)
fail("diagnostic_file_unsafe");
snapshots.push({ name: entry.name, handle, size: opened.size });
retained = true;
} finally {
if (!retained) await handle.close().catch(() => undefined);
}
}
const after = await lstat(directory);
if (
!after.isDirectory() ||
after.isSymbolicLink() ||
after.dev !== directoryInfo.dev ||
after.ino !== directoryInfo.ino
)
fail("diagnostic_directory_unsafe");
return snapshots.sort((left, right) => left.name.localeCompare(right.name));
} catch (error) {
await Promise.all(
snapshots.map((file) => file.handle.close().catch(() => undefined)),
);
throw error;
}
}
async function writeStreamingDiagnosticZip(
target: string,
archive: Record<string, Uint8Array>,
rawLogs: RawLogSnapshot[],
): Promise<void> {
const output = createWriteStream(target, { flags: "wx", mode: 0o600 });
let outputError: Error | null = null;
let backpressure = Promise.resolve();
const finished = new Promise<void>((resolve, reject) => {
output.once("finish", resolve);
output.once("error", (error) => {
outputError = error;
reject(error);
});
});
// The producer may still be reading a source file when the output stream
// fails. Attach a rejection handler immediately; the awaited promise below
// still carries the same error into the export's fail-closed cleanup.
void finished.catch(() => undefined);
const zip = new Zip((error, chunk, final) => {
if (error) {
outputError = error;
output.destroy(error);
return;
}
if (chunk.length > 0 && !output.write(chunk))
backpressure = once(output, "drain").then(() => undefined);
if (final) output.end();
});
const ensureWritable = async () => {
await backpressure;
if (outputError) throw outputError;
};
try {
for (const [name, content] of Object.entries(archive)) {
await ensureWritable();
const input = new ZipPassThrough(name);
zip.add(input);
input.push(content, true);
}
for (const file of rawLogs) {
await ensureWritable();
const input = new ZipPassThrough(`logs/${file.name}`);
zip.add(input);
let position = 0;
while (position < file.size) {
await ensureWritable();
const buffer = Buffer.allocUnsafe(
Math.min(64 * 1024, file.size - position),
);
const { bytesRead } = await file.handle.read(
buffer,
0,
buffer.length,
position,
);
if (bytesRead < 1) fail("diagnostic_file_changed");
position += bytesRead;
input.push(buffer.subarray(0, bytesRead), position === file.size);
}
if (file.size === 0) input.push(new Uint8Array(), true);
}
await ensureWritable();
zip.end();
await finished;
} catch (error) {
zip.terminate();
output.destroy(error instanceof Error ? error : undefined);
await finished.catch(() => undefined);
throw error;
}
}
export async function diagnosticLogFiles(dataDir: string): Promise<string[]> {
const directory = diagnosticDirectory(dataDir);
let directoryInfo: Awaited<ReturnType<typeof lstat>>;
+1 -1
View File
@@ -36,7 +36,7 @@ MEMMY_LICENSE = (
MEMMY_PROVENANCE = MEMMY_LICENSE.with_name("PROVENANCE.md")
MEMMY_PIN = "25acd5f29fc7df73b943713abe61f1b2f9839887"
HY_MEMORY_NAME = "hy-memory"
HY_MEMORY_VERSION = "2.0.13"
HY_MEMORY_VERSION = "2.0.14"
class SbomError(RuntimeError):
+1 -1
View File
@@ -12,7 +12,7 @@ test("installation docs disclose publication and lifecycle boundaries", async ()
"utf8",
);
assert.match(readme, /`hy-memory==2\.0\.13`/);
assert.match(readme, /`hy-memory==2\.0\.14`/);
assert.match(readme, /`v1\.0\.6` 的发行目标是 Linux x64\/arm64 Runtime/);
assert.match(readme, /Linux 制品由 GitHub Actions/);
assert.match(readme, /macOS.*Developer ID 签名、公证/s);
+3 -3
View File
@@ -13,7 +13,7 @@ spec.loader.exec_module(module)
def test_hy_memory_notice_comes_from_the_installed_dist_info(tmp_path: Path) -> None:
dist_info = tmp_path / "hy_memory-2.0.13.dist-info"
dist_info = tmp_path / "hy_memory-2.0.14.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.13",
"Version: 2.0.14",
"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.13"
assert notice["version"] == "2.0.14"
assert notice["license"] == "Apache-2.0"
assert notice["copyright"] == "Copyright (C) 2026 Tencent. All rights reserved."
assert "Licensed under the Apache License" in notice["text"]
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hy-memory"
version = "2.0.13"
version = "2.0.14"
description = "HY Memory - Industrial-grade dual-system cognitive memory framework"
readme = "README.md"
requires-python = ">=3.9"
+41 -2
View File
@@ -27,6 +27,7 @@ class RawStore:
exclude_sources,
layers,
limit,
status_filter,
**_kwargs,
):
rows = []
@@ -41,11 +42,18 @@ class RawStore:
continue
if node.layer not in layers:
continue
if node.status not in status_filter:
continue
rows.append({"node": node, "score": 0.9})
return rows[:limit]
def raw_node(raw_id: str, session_id: str, source: str) -> MemoryNode:
def raw_node(
raw_id: str,
session_id: str,
source: str,
status: MemoryStatus = MemoryStatus.ACTIVE,
) -> MemoryNode:
return MemoryNode(
node_id=raw_id,
user_id="u1",
@@ -55,7 +63,7 @@ def raw_node(raw_id: str, session_id: str, source: str) -> MemoryNode:
layer=MemoryLayer.L1_RAW,
content=f"content {raw_id}",
source_type=SourceType.EXPLICIT,
status=MemoryStatus.ACTIVE,
status=status,
is_latest=True,
embedding=[0.1, 0.2],
)
@@ -102,6 +110,7 @@ async def test_existing_search_contract_can_explicitly_search_raw():
request_id="search-1",
t0=0.0,
query_embedding=[0.1, 0.2],
statuses=None,
)
assert [item["memory_id"] for item in result["memories"]["normal"]] == [
@@ -109,3 +118,33 @@ async def test_existing_search_contract_can_explicitly_search_raw():
]
assert result["memories"]["normal"][0]["layer"] == "l1_raw"
assert result["memories"]["normal"][0]["source"] == "claude"
@pytest.mark.asyncio
async def test_explicit_raw_search_can_include_shadow_rows():
client = HyMemoryClient.__new__(HyMemoryClient)
client._vector_store = RawStore(
[raw_node("raw-shadow", "s1", "cursor", MemoryStatus.SHADOW)]
)
client._embed_service = AsyncMock()
client._embed_service.embed.return_value = [0.1, 0.2]
result = await HyMemoryClient._search_explicit_raw_layer.__wrapped__(
client,
"content",
user_ids=["u1"],
agent_ids=["a1"],
session_ids=[],
include_sources=[],
exclude_sources=[],
limit=5,
min_score=0.0,
created_after=None,
request_id="search-shadow",
t0=0.0,
query_embedding=[0.1, 0.2],
statuses=["active", "shadow"],
)
assert result["memories"]["normal"][0]["memory_id"] == "raw-shadow"
assert result["memories"]["normal"][0]["status"] == "shadow"