Compare commits

..
Author SHA1 Message Date
kristinheandCursor f6713a4343 fix(memhub): share model probe readiness
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 03:07:59 +08:00
kristinhe1111 4cbf4f1501 feat(memhub): offer all memory modes during onboarding 2026-11-05 02:40:34 +08:00
kristinhe1111 e5959578c8 fix(memhub): let Lite onboarding complete immediately 2026-11-05 01:48:15 +08:00
kristinhe1111 df5a65797c fix(memhub): page Lite raw turns without full scans 2026-11-05 00:55:07 +08:00
kristinhe1111 2ea48e68a6 Merge pull request #326 from Tencent-Hunyuan/fix/home-recent-update-bottom-align
fix(memhub): align recent activity to card bottom
2026-11-05 00:31:55 +08:00
kristinhe1111 b8fdde7d0b Merge pull request #327 from Tencent-Hunyuan/fix/model-technical-hover
fix(web): preserve model details hover contrast
2026-11-05 00:31:36 +08:00
kristinhe1111 5134524743 Merge pull request #328 from Tencent-Hunyuan/fix/ultra-evolve-time-picker
fix(memhub): replace native evolve time input
2026-11-05 00:31:20 +08:00
kristinhe1111 e34101cb0b Merge pull request #329 from Sthreal/fix/301-model-probe-pending-state
fix(memhub): fail model probe reservations when execution never dispatches
2026-11-05 00:31:00 +08:00
kristinhe1111 995c7e6c32 Merge pull request #330 from Tencent-Hunyuan/fix/memhub-complete-log-export
fix(memhub): export complete logs from Settings
2026-11-05 00:30:38 +08:00
kristinheandCursor f1afdb03cd fix(memhub): export complete logs from settings
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 23:49:21 +08:00
Sthreal 4df4ce72bd fix(memhub): fail model probe reservations when execution never dispatches 2026-11-04 23:33:06 +08:00
kristinheandCursor a6d9894975 fix(memhub): replace native evolve time input
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 22:31:00 +08:00
kristinheandCursor 218bcf779b test: stabilize reembed merge fixture timestamps
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 22:11:46 +08:00
kristinheandCursor 7e4ee0d8ea fix(web): preserve model details hover contrast
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 21:48:08 +08:00
kristinheandCursor bce58e638d fix(memhub): align recent activity to card bottom
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 21:47:57 +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 93d0c64490 style(memhub): format graph topic changes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 16:10:30 +08:00
kristinheandCursor 1589ae31bc fix(memhub): make topic nodes hover-only
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 16:06:56 +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 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
125 changed files with 6762 additions and 1143 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",
+532 -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,12 @@ 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"
next_cursor: str | None = None
has_more: bool = False
total_exact: bool = True
#: 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 +748,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 +1513,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 +1642,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 +1885,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 +1970,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 +2010,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 +2100,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 +2842,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 +3030,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 +3051,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 +3096,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 +3120,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 +3137,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 +3210,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 +3505,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 +3605,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 +3614,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 +3628,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 +4474,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 +4493,9 @@ 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"),
paging: Literal["offset", "cursor"] = Query(default="offset"),
cursor: str | None = Query(default=None, max_length=160),
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 +4509,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`.
@@ -4472,29 +4521,352 @@ def create_app(
_library_values(statuses or [], MEMORY_STATUSES, "memory_status_unknown")
or raw_list_filters()["statuses"]
)
if paging == "cursor" and unit != "turn":
raise HTTPException(status_code=422, detail="raw_cursor_requires_turn_unit")
if cursor is not None and paging != "cursor":
raise HTTPException(status_code=422, detail="raw_cursor_requires_cursor_paging")
if paging == "cursor" and (offset or after is not None or before is not None):
raise HTTPException(status_code=422, detail="raw_cursor_filter_unsupported")
raw_cursor = (0, 0, 0, 0, "", "")
if cursor:
parts = cursor.split(":")
if (
len(parts) != 7
or parts[0] != "turn"
or any(re.fullmatch(r"[0-9]+", part) is None for part in parts[1:5])
or re.fullmatch(r"(?:p?[0-9a-f]{16})?", parts[5]) is None
or re.fullmatch(r"[0-9a-f]{16}", parts[6]) is None
):
raise HTTPException(status_code=422, detail="raw_cursor_invalid")
raw_cursor = (
int(parts[1]),
int(parts[2]),
int(parts[3]),
int(parts[4]),
parts[5],
parts[6],
)
if any(value > 100_000_000 for value in raw_cursor[:4]):
raise HTTPException(status_code=422, detail="raw_cursor_invalid")
sources = split_csv(source)
excluded = split_csv(exclude_sources)
sessions = split_csv(session_id)
cursor_scope = hashlib.sha256(
json.dumps(
{
"user_id": user_id,
"statuses": sorted(chosen_statuses),
"sources": sorted(sources),
"excluded": sorted(excluded),
"sessions": sorted(sessions),
"order": order,
},
sort_keys=True,
separators=(",", ":"),
).encode()
).hexdigest()[:16]
if cursor and raw_cursor[5] != cursor_scope:
raise HTTPException(status_code=422, detail="raw_cursor_scope_mismatch")
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" or paging == "cursor" else "desc",
}
if unit == "turn" and paging == "cursor":
(
raw_offset,
turn_offset,
emitted,
previous_total,
expected_raw,
_scope,
) = raw_cursor
value = await asyncio.to_thread(
client.list_memories,
**query,
offset=raw_offset,
limit=min(limit + 1, 500),
)
raw_page = HySdkRawPage.from_memory_list(
value, limit=min(limit + 1, 500), offset=raw_offset
)
def raw_anchor(record: HySdkRawRecord) -> str:
return hashlib.sha256(
f"{record.raw_id}\0{record.content}".encode()
).hexdigest()[:16]
if cursor:
if raw_page.total < previous_total:
raise HTTPException(status_code=409, detail="raw_cursor_stale")
observed_total = raw_page.total
added = raw_page.total - previous_total
if added and order == "desc":
raw_offset += added
value = await asyncio.to_thread(
client.list_memories,
**query,
offset=raw_offset,
limit=min(limit + 1, 500),
)
raw_page = HySdkRawPage.from_memory_list(
value,
limit=min(limit + 1, 500),
offset=raw_offset,
)
if raw_page.total != observed_total:
raise HTTPException(
status_code=409, detail="raw_cursor_stale"
)
if expected_raw.startswith("p"):
if raw_offset == 0:
raise HTTPException(
status_code=409, detail="raw_cursor_stale"
)
previous_value = await asyncio.to_thread(
client.list_memories,
**query,
offset=raw_offset - 1,
limit=1,
)
previous_page = HySdkRawPage.from_memory_list(
previous_value, limit=1, offset=raw_offset - 1
)
valid_anchor = (
previous_page.total == raw_page.total
and bool(previous_page.raw)
and raw_anchor(previous_page.raw[0])
== expected_raw[1:]
)
else:
valid_anchor = (
bool(raw_page.raw)
and raw_anchor(raw_page.raw[0]) == expected_raw
)
if expected_raw and not valid_anchor:
raise HTTPException(status_code=409, detail="raw_cursor_stale")
selected_turns: list[tuple[HySdkRawRecord, RawDisplayTurn]] = []
next_cursor: str | None = None
for record_index, record in enumerate(raw_page.raw):
turns = raw_display_turns(record)
ordered_turns = (
tuple(reversed(turns)) if order == "desc" else turns
)
start = turn_offset if record_index == 0 else 0
for index in range(start, len(ordered_turns)):
selected_turns.append((record, ordered_turns[index]))
if len(selected_turns) < limit:
continue
absolute_raw = raw_offset + record_index
next_raw = absolute_raw
next_turn = index + 1
if next_turn >= len(ordered_turns):
next_raw += 1
next_turn = 0
if next_raw < raw_page.total:
if next_turn:
anchor_record = record
else:
next_index = record_index + 1
anchor_record = (
raw_page.raw[next_index]
if next_index < len(raw_page.raw)
else None
)
anchor = (
raw_anchor(anchor_record)
if anchor_record is not None
else f"p{raw_anchor(record)}"
)
next_cursor = (
f"turn:{next_raw}:{next_turn}:"
f"{emitted + len(selected_turns)}:{raw_page.total}:"
f"{anchor}:{cursor_scope}"
)
break
if len(selected_turns) >= limit:
break
if (
next_cursor is None
and raw_offset + len(raw_page.raw) < raw_page.total
):
if not raw_page.raw:
raise HTTPException(
status_code=409, detail="raw_cursor_stale"
)
next_raw = raw_offset + len(raw_page.raw)
previous_anchor = raw_anchor(raw_page.raw[-1])
next_cursor = (
f"turn:{next_raw}:0:{emitted + len(selected_turns)}:"
f"{raw_page.total}:p{previous_anchor}:{cursor_scope}"
)
return SdkRawPage(
user_id=user_id,
items=tuple(
SdkRawItem.of(record, turn)
for record, turn in selected_turns
),
# Cursor consumers do not use a precise total. This lower
# bound preserves the legacy numeric field without forcing
# a full scan merely to count variable-size turn batches.
total=emitted + len(selected_turns) + (1 if next_cursor else 0),
offset=emitted,
limit=limit,
unit="turn",
next_cursor=next_cursor,
has_more=next_cursor is not None,
total_exact=False,
)
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 +4881,7 @@ def create_app(
total=page.total,
offset=offset,
limit=limit,
unit="raw",
time_filtered=time_filtered,
)
@@ -4705,6 +5078,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 +5126,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 +5925,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 +6209,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 +6656,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 +6683,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 +7299,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
@@ -1,6 +1,6 @@
import hashlib
import os
from collections.abc import AsyncIterator, Iterator
from collections.abc import AsyncIterator, Callable, Iterator
from dataclasses import dataclass
from pathlib import Path
@@ -28,6 +28,7 @@ def discovered_off_loop(
suffixes: frozenset[str],
max_targets: int,
excluded_directory_names: frozenset[str] = frozenset(),
directory_predicate: Callable[[Path], bool] | None = None,
) -> AsyncIterator[SourceFileDiscoveryEvent]:
"""The same walk, for callers that have an event loop to keep free.
@@ -43,6 +44,7 @@ def discovered_off_loop(
suffixes=suffixes,
max_targets=max_targets,
excluded_directory_names=excluded_directory_names,
directory_predicate=directory_predicate,
),
scope_key=grant.source_id,
)
@@ -54,6 +56,7 @@ def discover_source_files(
suffixes: frozenset[str],
max_targets: int,
excluded_directory_names: frozenset[str] = frozenset(),
directory_predicate: Callable[[Path], bool] | None = None,
) -> Iterator[SourceFileDiscoveryEvent]:
"""Metadata only: one lstat per candidate, no open and no per-file grant lookup.
@@ -71,10 +74,16 @@ def discover_source_files(
grant.root,
followlinks=False,
):
current = Path(directory)
child_directories[:] = sorted(
child
for child in child_directories
if not (Path(directory) / child).is_symlink() and child not in excluded_directory_names
if not (current / child).is_symlink()
and child not in excluded_directory_names
and (
directory_predicate is None
or directory_predicate((current / child).relative_to(grant.root))
)
)
for filename in sorted(filenames):
if Path(filename).suffix.lower() not in suffixes:
+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
@@ -557,7 +591,11 @@ class PollingLiveSyncSupervisor:
async def _poll_source(self, source_id: str) -> None:
while True:
await asyncio.sleep(self.interval_seconds)
# A watched source is normally triggered by its filesystem event.
# Its timer is only a missed-event backstop; recursively walking
# every multi-gigabyte host root once a minute starved unrelated
# Core reads even when no source had changed.
await asyncio.sleep(self._poll_interval(source_id))
try:
await self.trigger(source_id)
except asyncio.CancelledError:
@@ -574,6 +612,11 @@ class PollingLiveSyncSupervisor:
except Exception as again:
self._note_internal_error(source_id, again)
def _poll_interval(self, source_id: str) -> float:
if source_id in (self._watched_sources or ()):
return max(self.interval_seconds, 15 * 60)
return self.interval_seconds
def _note_worker_still_running(self, source_id: str) -> None:
current = self.health(source_id)
self._set_health(
@@ -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:
@@ -226,6 +226,9 @@ class JsonlSourceAdapter:
self.header_gate = header_gate
self._header_cache: dict[str, tuple[str, dict[str, JsonValue] | None]] = {}
self._history_listing_cache: SourceListing | None = None
self._probe_listing_cache: tuple[
dict[str, SourceCursor], int | None, SourceListing
] | None = None
@property
def descriptor(self) -> SourceDescriptor:
@@ -281,6 +284,11 @@ class JsonlSourceAdapter:
predicate=self.file_predicate,
cursors=options.cursors,
)
self._probe_listing_cache = (
dict(options.cursors),
options.max_scan_targets,
listing,
)
return bool(listing.changed or listing.failures)
async def live_baseline(self) -> dict[str, SourceCursor]:
@@ -315,9 +323,11 @@ class JsonlSourceAdapter:
async for event in self._scan_frozen(options):
yield event
return
listing = await asyncio.to_thread(
self._history_listing if options.reuse_listing else self._fresh_listing,
options,
listing = (
await asyncio.to_thread(self._history_listing, options)
if options.reuse_listing
else self._take_probe_listing(options)
or await asyncio.to_thread(self._fresh_listing, options)
)
for failure in listing.failures:
yield ScanFailure(reason_code=discovery_failure_code(failure))
@@ -349,6 +359,16 @@ class JsonlSourceAdapter:
self._history_listing_cache = listing
return listing
def _take_probe_listing(self, options: ScanOptions) -> "SourceListing | None":
cached = self._probe_listing_cache
self._probe_listing_cache = None
if cached is None:
return None
cursors, max_scan_targets, listing = cached
if cursors != options.cursors or max_scan_targets != options.max_scan_targets:
return None
return listing
def _fresh_listing(self, options: ScanOptions) -> "SourceListing":
return list_changed_files(
self.grant,
@@ -9,6 +9,7 @@ from collections.abc import AsyncIterator, Iterator
from dataclasses import dataclass, replace
from datetime import UTC, datetime, timedelta, timezone
from functools import partial
from pathlib import Path
from pydantic import JsonValue
@@ -175,6 +176,9 @@ class CursorUnifiedSourceAdapter:
self.grant = grant
self.source_version = source_version
self._history_listing_cache: _CursorListing | None = None
self._probe_listing_cache: tuple[
dict[str, SourceCursor], int | None, _CursorListing
] | None = None
@property
def descriptor(self) -> SourceDescriptor:
@@ -207,6 +211,20 @@ class CursorUnifiedSourceAdapter:
state = await self.install_state()
return min(state.session_count, limit) if limit is not None else state.session_count
async def has_changes(self, options: ScanOptions) -> bool:
listing = await asyncio.to_thread(self._fresh_listing, options)
self._probe_listing_cache = (
dict(options.cursors),
options.max_scan_targets,
listing,
)
return bool(
listing.transcripts
or listing.stores
or listing.legacy
or listing.failures
)
async def live_baseline(self) -> dict[str, SourceCursor]:
"""Capture Cursor's current tail so the realtime lane never backfills history."""
listing = await asyncio.to_thread(
@@ -222,9 +240,11 @@ class CursorUnifiedSourceAdapter:
async for event in self._scan_frozen(options):
yield event
return
listing = await asyncio.to_thread(
self._history_listing if options.reuse_listing else self._fresh_listing,
options,
listing = (
await asyncio.to_thread(self._history_listing, options)
if options.reuse_listing
else self._take_probe_listing(options)
or await asyncio.to_thread(self._fresh_listing, options)
)
for failure in listing.failures:
yield ScanFailure(reason_code=discovery_failure_code(failure))
@@ -329,6 +349,16 @@ class CursorUnifiedSourceAdapter:
self._history_listing_cache = listing
return listing
def _take_probe_listing(self, options: ScanOptions) -> "_CursorListing | None":
cached = self._probe_listing_cache
self._probe_listing_cache = None
if cached is None:
return None
cursors, max_scan_targets, listing = cached
if cursors != options.cursors or max_scan_targets != options.max_scan_targets:
return None
return listing
def _fresh_listing(self, options: ScanOptions) -> "_CursorListing":
return _list_cursor_files(
self.grant,
@@ -376,6 +406,7 @@ class CursorUnifiedSourceAdapter:
self.grant,
suffixes=frozenset({".db"}),
max_targets=options.max_scan_targets or 100_000,
directory_predicate=_cursor_data_directory,
):
if isinstance(discovered, SourceFileDiscoveryFailure):
yield ScanFailure(reason_code=discovery_failure_code(discovered))
@@ -477,7 +508,10 @@ def _list_cursor_files(
legacy: list[AuthorizedSourceFile] = []
failures: list[SourceFileDiscoveryFailure] = []
for item in discover_source_files(
grant, suffixes=frozenset({".jsonl", ".db", ".vscdb"}), max_targets=max_targets
grant,
suffixes=frozenset({".jsonl", ".db", ".vscdb"}),
max_targets=max_targets,
directory_predicate=_cursor_data_directory,
):
if isinstance(item, SourceFileDiscoveryFailure):
failures.append(item)
@@ -586,6 +620,25 @@ def _is_cursor_data_file(source_file: AuthorizedSourceFile) -> bool:
)
def _cursor_data_directory(relative_path: Path) -> bool:
"""Enter only Cursor directories that can contain conversation authority.
The granted root also contains extensions, skills, caches, logs and agent
artifacts. Recursively walking all of them every live-sync poll made the
reader monopolize Core even though none can pass the file predicates.
"""
parts = relative_path.parts
if not parts:
return False
if parts[0] in {"chats", "acp-sessions"}:
return True
if parts[0] == "projects":
return len(parts) <= 2 or parts[2] == "agent-transcripts"
if parts[0] == "User":
return len(parts) == 1 or parts[1] in {"globalStorage", "workspaceStorage"}
return False
def _is_transcript_file(source_file: AuthorizedSourceFile) -> bool:
parts = source_file.relative_path.parts
return source_file.relative_path.suffix == ".jsonl" and "agent-transcripts" in parts
@@ -634,6 +687,7 @@ def _select_transcript_files(
grant,
suffixes=frozenset({".jsonl"}),
max_targets=100_000,
directory_predicate=_cursor_data_directory,
):
if not isinstance(item, AuthorizedSourceFile) or not _is_transcript_file(item):
continue
@@ -654,6 +708,7 @@ def _subagent_conversation_ids(grant: AuthorizedSourceRoot) -> set[str]:
grant,
suffixes=frozenset({".jsonl"}),
max_targets=100_000,
directory_predicate=_cursor_data_directory,
)
if isinstance(item, AuthorizedSourceFile)
and _is_transcript_file(item)
@@ -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,
),
)
+16 -3
View File
@@ -26,6 +26,7 @@ if TYPE_CHECKING:
_operation: ContextVar[tuple["ModelProbeOperations", str] | None] = ContextVar(
"model_probe_operation", default=None
)
_dispatch_started: ContextVar[bool] = ContextVar("model_probe_dispatch_started", default=False)
class ProbeCode(StrEnum):
@@ -113,16 +114,26 @@ class ModelProber:
if existing is not None:
return existing
token = _operation.set((operations, request.operation_id))
dispatch_token = _dispatch_started.set(False)
try:
await self._probe(profile_id)
except ProbeOperationConflict:
await asyncio.to_thread(
operations.fail, request.operation_id, "model_probe_profile_changed"
)
except Exception:
# Unknown dispatch/storage outcomes stay reserved, including across restart.
raise ProbeOperationUnavailable("model_probe_operation_unavailable") from None
except Exception as exc:
if _dispatch_started.get():
# Once a request may have reached the provider, preserve the
# reservation so a retry cannot duplicate a possibly billed call.
raise ProbeOperationUnavailable("model_probe_operation_unavailable") from None
logging.getLogger(__name__).warning(
"model probe failed before dispatch: %s", type(exc).__name__
)
await asyncio.to_thread(
operations.fail, request.operation_id, "model_probe_execution_failed"
)
finally:
_dispatch_started.reset(dispatch_token)
_operation.reset(token)
return await asyncio.to_thread(operations.lookup, request.operation_id)
@@ -317,6 +328,8 @@ class ModelProber:
listing_optional: bool = False,
) -> ProbeStage:
await self._check_operation()
if _operation.get() is not None:
_dispatch_started.set(True)
started = monotonic()
try:
response = await client.request(
@@ -118,7 +118,7 @@ class OnboardingState(BaseModel):
selected_source_ids: tuple[str, ...]
started_at: datetime | None
completed_at: datetime | None
mode_intent: Literal["lite", "pro"] | None
mode_intent: Literal["lite", "pro", "ultra"] | None
setup_step: Literal["review", "mode", "model"] | None
model_profile_id: str | None
@@ -128,7 +128,7 @@ class OnboardingProgressUpdate(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
mode_intent: Literal["lite", "pro"] | None = None
mode_intent: Literal["lite", "pro", "ultra"] | None = None
setup_step: Literal["review", "mode", "model"] | None = None
model_profile_id: str | None = Field(default=None, min_length=36, max_length=36)
@@ -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())
+34
View File
@@ -293,6 +293,11 @@ def test_health_states_that_watching_is_layered_on_the_timer(tmp_path: Path) ->
assert live.health(PI_DESCRIPTOR.source_id).mode == "watch_and_polling"
assert live.health("unknown-source").mode == "watch_and_polling"
live.set_watched_sources((PI_DESCRIPTOR.source_id,))
assert live._poll_interval(PI_DESCRIPTOR.source_id) == max(
live.interval_seconds, 15 * 60
)
assert live._poll_interval("unknown-source") == live.interval_seconds
await imports.shutdown()
asyncio.run(scenario())
@@ -641,3 +646,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
@@ -91,6 +91,51 @@ def test_reserved_before_transport_and_atomic_result_reopens_without_resending(e
asyncio.run(scenario())
def test_pre_dispatch_failure_marks_operation_failed_and_unfences(env, monkeypatch):
store, secrets, profile, ops, calls, respond = env
operation = str(uuid4())
def fail_require(*args, **kwargs):
raise RuntimeError("profile load failed")
prober = ModelProber(store=store, secrets=secrets, transport=httpx.MockTransport(respond))
with monkeypatch.context() as patch:
patch.setattr(store, "require", fail_require)
receipt = asyncio.run(
prober.probe(
profile.profile_id,
operation_id=operation,
expected_profile_revision=profile.revision,
)
)
assert receipt.state == "failed"
assert receipt.code == "model_probe_execution_failed"
assert receipt.result is None
assert calls == []
assert ops.lookup(operation) == receipt
assert (
asyncio.run(
prober.probe(
profile.profile_id,
operation_id=operation,
expected_profile_revision=profile.revision,
)
)
== receipt
)
completed = asyncio.run(
prober.probe(
profile.profile_id,
operation_id=str(uuid4()),
expected_profile_revision=profile.revision,
)
)
assert completed.state == "completed"
assert len(calls) == 3
def test_same_id_and_same_profile_concurrency_refused_before_second_call(env):
store, secrets, profile, ops, calls, respond = env
@@ -149,6 +149,13 @@ def test_progress_patch_and_put_round_trip_only_resumable_choices(tmp_path: Path
assert replaced.json()["model_profile_id"] == profile_id
assert (await client.get("/api/v1/onboarding")).json() == replaced.json()
ultra = await client.put(
"/api/v1/onboarding/progress",
json={"mode_intent": "ultra", "setup_step": "model"},
)
assert ultra.status_code == 200
assert ultra.json()["mode_intent"] == "ultra"
asyncio.run(scenario())
@@ -166,7 +173,7 @@ def test_progress_rejects_unknown_or_invalid_fields(tmp_path: Path) -> None:
)
invalid_mode = await client.patch(
"/api/v1/onboarding/progress",
json={"mode_intent": "ultra"},
json={"mode_intent": "future"},
)
assert secret.status_code == 422
@@ -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
]
),
)
)
@@ -77,6 +77,36 @@ def test_workbuddy_record_parsing_never_blocks_the_event_loop(tmp_path) -> None:
asyncio.run(scenario())
def test_jsonl_live_probe_listing_is_reused_by_the_import(tmp_path, monkeypatch) -> None:
root = tmp_path / ".workbuddy" / "projects"
write_jsonl(
root / "project" / "session.jsonl",
[{"role": "user", "sessionId": "session", "content": "new conversation"}],
)
adapter = create_workbuddy_adapter(grant("workbuddy", root))
from one_memory.imports.sources import common
calls = 0
original = common.list_changed_files
def counted(*args, **kwargs):
nonlocal calls
calls += 1
return original(*args, **kwargs)
monkeypatch.setattr(common, "list_changed_files", counted)
async def probe_then_scan():
options = ScanOptions()
assert await adapter.has_changes(options)
return [event async for event in adapter.scan(options)]
records = evidence(asyncio.run(probe_then_scan()))
assert [item.evidence.verbatim for item in records] == ["new conversation"]
assert calls == 1
def test_cursor_store_user_info_envelope_is_not_attributed_to_the_person(tmp_path) -> None:
"""A Cursor store blob can be the host's session context, not the user's words.
@@ -1352,6 +1382,73 @@ def test_cursor_transcripts_with_the_same_name_in_two_projects_are_both_kept(
assert len({item.evidence.source_ordinal for item in records}) == 2
def test_cursor_discovery_does_not_enter_non_conversation_trees(tmp_path) -> None:
root = tmp_path / ".cursor"
write_jsonl(
root / "projects" / "project" / "agent-transcripts" / "kept" / "kept.jsonl",
[
{
"role": "user",
"message": {"content": [{"type": "text", "text": "kept conversation"}]},
}
],
)
write_jsonl(
root
/ "extensions"
/ "large-cache"
/ "agent-transcripts"
/ "ignored"
/ "ignored.jsonl",
[
{
"role": "user",
"message": {"content": [{"type": "text", "text": "must not be scanned"}]},
}
],
)
records = evidence(asyncio.run(scan(CursorUnifiedSourceAdapter(grant=grant("cursor", root)))))
assert [item.evidence.verbatim for item in records] == ["kept conversation"]
def test_cursor_live_probe_listing_is_reused_by_the_import(tmp_path, monkeypatch) -> None:
root = tmp_path / ".cursor"
write_jsonl(
root / "projects" / "project" / "agent-transcripts" / "new" / "new.jsonl",
[
{
"role": "user",
"message": {"content": [{"type": "text", "text": "new conversation"}]},
}
],
)
adapter = CursorUnifiedSourceAdapter(grant=grant("cursor", root))
from one_memory.imports.adapters import ScanOptions
from one_memory.imports.sources import cursor_unified
calls = 0
original = cursor_unified._list_cursor_files
def counted(*args, **kwargs):
nonlocal calls
calls += 1
return original(*args, **kwargs)
monkeypatch.setattr(cursor_unified, "_list_cursor_files", counted)
async def probe_then_scan():
options = ScanOptions()
assert await adapter.has_changes(options)
return [event async for event in adapter.scan(options)]
records = evidence(asyncio.run(probe_then_scan()))
assert [item.evidence.verbatim for item in records] == ["new conversation"]
assert calls == 1
def test_cursor_bounded_scans_reuse_the_authorized_file_listing(tmp_path, monkeypatch) -> None:
root = tmp_path / ".cursor"
transcript = root / "projects" / "project" / "agent-transcripts" / "session" / "session.jsonl"
+247 -3
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"]:
@@ -173,7 +214,15 @@ class RecordingSdk:
)
and (not kwargs.get("session_ids") or row["session_id"] in kwargs["session_ids"])
]
return {"vdb": {"memories": rows, "total": len(rows)}}
total = len(rows)
offset = kwargs.get("offset", 0)
limit = kwargs.get("limit", 100)
return {
"vdb": {
"memories": rows[offset : offset + limit],
"total": total,
}
}
rows = [_chat(user_id, *row[:2], *row[3:]) for row in CHAT_ROWS.get(user_id, [])]
if layers:
rows = [row for row in rows if row["layer"] in layers]
@@ -271,6 +320,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 +400,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 +696,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 +711,123 @@ 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] == [
["我喜欢绿茶", "也记住了。"],
["我叫费天翔", "好的,记住了。"],
]
cursor = None
first_cursor = None
cursor_turns = []
cursor_calls = len(sdk.all("list_memories"))
while True:
params = {"unit": "turn", "paging": "cursor", "limit": 1}
if cursor is not None:
params["cursor"] = cursor
response = await client.get("/api/v2/raw", params=params)
assert response.status_code == 200, response.text
cursor_page = response.json()
assert cursor_page["total_exact"] is False
assert len(cursor_page["items"]) == 1
cursor_turns.append(cursor_page["items"][0]["messages"][0]["content"])
cursor = cursor_page["next_cursor"]
if first_cursor is None:
first_cursor = cursor
if cursor is None:
assert cursor_page["has_more"] is False
break
assert cursor_page["has_more"] is True
assert cursor_turns == ["我喜欢绿茶", "我叫费天翔", "你好"]
assert len(sdk.all("list_memories")) == cursor_calls + 3
inserted = {
"memory_id": "raw-new",
"content": "[user]: 新消息",
"status": "active",
"session_id": "s-new",
"source": "cursor",
"memory_at": 1_790_200_000,
"gmt_created": 1_790_200_100,
}
RAW_ROWS["local"].insert(0, inserted)
try:
continued = await client.get(
"/api/v2/raw",
params={
"unit": "turn",
"paging": "cursor",
"limit": 1,
"cursor": first_cursor,
},
)
assert continued.status_code == 200, continued.text
assert continued.json()["items"][0]["messages"][0]["content"] == "我叫费天翔"
finally:
RAW_ROWS["local"].remove(inserted)
assert (
await client.get(
"/api/v2/raw",
params={
"unit": "turn",
"paging": "cursor",
"cursor": first_cursor,
"source": "hermes",
},
)
).status_code == 422
assert (
await client.get(
"/api/v2/raw",
params={
"unit": "turn",
"paging": "cursor",
"cursor": "turn:²:0:0:2::0000000000000000",
},
)
).status_code == 422
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")}>
+17 -1
View File
@@ -6,7 +6,11 @@ import type {
} from "@one-memory/contracts";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { MemoryGraph3D } from "./MemoryGraph3D";
import {
graphNodeTooltip,
isGraphNodeSelectable,
MemoryGraph3D,
} from "./MemoryGraph3D";
afterEach(cleanup);
@@ -48,6 +52,18 @@ function makeGraph(nodeCount: number): MemoryGraphResponse {
}
describe("MemoryGraph3D", () => {
it("keeps topic annotations hover-only", () => {
const topic = {
...makeGraph(1).nodes[0],
node_type: "topic" as const,
label: "CI",
};
expect(isGraphNodeSelectable(topic)).toBe(false);
expect(graphNodeTooltip(topic, "主题:")).toBe("主题:CI");
expect(graphNodeTooltip(topic, "Topic: ")).toBe("Topic: CI");
expect(isGraphNodeSelectable({ ...topic, node_type: "memory" })).toBe(true);
});
it("没有 WebGL 时给出可访问降级提示", () => {
render(
<MemoryGraph3D
+58 -24
View File
@@ -27,6 +27,20 @@ const SELECT_DURATION_MS = 180;
const SELECTED_COLOR = new THREE.Color(0x294355);
const SELECTED_EMISSIVE = new THREE.Color(0x607e97);
/** Topic nodes explain the graph; unlike memories and sources, they do not own a detail view. */
export function isGraphNodeSelectable(node: MemoryGraphNode): boolean {
return node.node_type !== "topic";
}
export function graphNodeTooltip(
node: MemoryGraphNode,
topicPrefix: string,
): string {
return node.node_type === "topic"
? `${topicPrefix}${node.label}`
: node.label;
}
export function MemoryGraph3D({
graph,
selectedNodeId,
@@ -36,10 +50,20 @@ export function MemoryGraph3D({
selectedNodeId: string | null;
onSelectNode(node: MemoryGraphNode): void;
}) {
const { text } = useMemoryLanguage();
const { locale, text } = useMemoryLanguage();
const host = useRef<HTMLDivElement>(null);
const labelHost = useRef<HTMLDivElement>(null);
const selectRef = useRef(onSelectNode);
const nodesByIdRef = useRef(
new Map(graph.nodes.map((node) => [node.node_id, node])),
);
nodesByIdRef.current = new Map(
graph.nodes.map((node) => [node.node_id, node]),
);
const topicPrefixRef = useRef("");
topicPrefixRef.current = `${text("主题")}${
locale.startsWith("zh") || locale.startsWith("ja") ? ":" : ": "
}`;
// 选中态通过 ref 传入渲染循环,而不是进 useEffect 依赖。
// 依赖里带 selectedNodeId 会导致每次点击重建整个场景、重跑布局、重建全部 mesh。
const selectedRef = useRef(selectedNodeId);
@@ -157,9 +181,7 @@ export function MemoryGraph3D({
}
}
const labels = graph.nodes
.filter(
(node) => node.node_type === "source" || node.node_type === "topic",
)
.filter((node) => node.node_type === "source")
.slice(0, 4)
.map((node) => {
const element = document.createElement("span");
@@ -194,7 +216,7 @@ export function MemoryGraph3D({
}
};
const selectable: THREE.Mesh[] = [];
const hittable: THREE.Mesh[] = [];
const materials = new Set<THREE.Material>();
const geometryByType = {
source: new THREE.SphereGeometry(0.24, 18, 12),
@@ -237,7 +259,7 @@ export function MemoryGraph3D({
mesh.scale.setScalar(1);
mesh.visible = returning || extending;
scene.add(mesh);
selectable.push(mesh);
hittable.push(mesh);
visuals.push({
mesh,
material,
@@ -298,7 +320,6 @@ export function MemoryGraph3D({
});
}
const nodesById = new Map(graph.nodes.map((node) => [node.node_id, node]));
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
const hitAt = (event: PointerEvent) => {
@@ -307,7 +328,7 @@ export function MemoryGraph3D({
pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
pointer.y = -((event.clientY - bounds.top) / bounds.height) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
return raycaster.intersectObjects(selectable, false)[0];
return raycaster.intersectObjects(hittable, false)[0];
};
let pressed: { x: number; y: number; id: number; moved: boolean } | null =
null;
@@ -333,8 +354,9 @@ export function MemoryGraph3D({
)
return;
const nodeId = hitAt(event)?.object.userData.nodeId;
const node = typeof nodeId === "string" ? nodesById.get(nodeId) : null;
if (node) selectRef.current(node);
const node =
typeof nodeId === "string" ? nodesByIdRef.current.get(nodeId) : null;
if (node && isGraphNodeSelectable(node)) selectRef.current(node);
};
const hoverNode = (event: PointerEvent) => {
if (pressed) {
@@ -344,12 +366,18 @@ export function MemoryGraph3D({
return;
}
const nodeId = hitAt(event)?.object.userData.nodeId;
const node = typeof nodeId === "string" ? nodesById.get(nodeId) : null;
renderer.domElement.style.cursor = node ? "pointer" : "grab";
const node =
typeof nodeId === "string" ? nodesByIdRef.current.get(nodeId) : null;
renderer.domElement.style.cursor =
node && isGraphNodeSelectable(node)
? "pointer"
: node
? "default"
: "grab";
setHovered(
node
? {
label: node.label,
label: graphNodeTooltip(node, topicPrefixRef.current),
x: event.clientX - element.getBoundingClientRect().left,
y: event.clientY - element.getBoundingClientRect().top,
}
@@ -629,17 +657,23 @@ export function MemoryGraph3D({
{!available ? (
<div className="memory-graph-fallback">
<p>{text("这台设备打不开 3D 视图,已显示关联列表。")}</p>
{graph.nodes.map((node) => (
<button
type="button"
className="secondary"
key={node.node_id}
aria-pressed={selectedNodeId === node.node_id}
onClick={() => onSelectNode(node)}
>
{node.label}
</button>
))}
{graph.nodes.map((node) =>
isGraphNodeSelectable(node) ? (
<button
type="button"
className="secondary"
key={node.node_id}
aria-pressed={selectedNodeId === node.node_id}
onClick={() => onSelectNode(node)}
>
{node.label}
</button>
) : (
<span className="memory-graph-fallback-topic" key={node.node_id}>
{graphNodeTooltip(node, topicPrefixRef.current)}
</span>
),
)}
</div>
) : null}
{available && (
+7 -7
View File
@@ -149,7 +149,7 @@ it("OBS-22: the graph inspector has no editing entry and never says changes cann
expect(screen.queryByText(/暂不能保存更改/)).toBeNull();
});
it("replaces a topic detail with an off-page graph memory detail", async () => {
it("keeps topic annotations out of the inspector and opens detail-bearing graph nodes", async () => {
const onOpen = vi.fn();
const topicGraph = {
total_memories: 2,
@@ -221,13 +221,13 @@ it("replaces a topic detail with an off-page graph memory detail", async () => {
/>,
);
await act(async () => {});
onOpen.mockClear();
await userEvent.click(screen.getByRole("button", { name: "图中选择 git" }));
expect(screen.getByRole("heading", { name: "git" })).toBeTruthy();
expect(screen.getAllByText("第一条").length).toBeGreaterThan(0);
expect(screen.queryByText("对应记忆")).toBeNull();
expect(screen.queryByText("属于主题")).toBeNull();
expect(screen.queryByText("主题")).toBeNull();
expect(screen.queryByText("两跳")).toBeNull();
expect(onOpen).not.toHaveBeenCalled();
expect(screen.queryByRole("heading", { name: "git" })).toBeNull();
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
expect(screen.getByTestId("canvas").dataset.selected).toBe("memory:m0");
await userEvent.click(
screen.getByRole("button", { name: "图中选择 先验证,再推送" }),
);
+81 -61
View File
@@ -110,12 +110,15 @@ type Props = {
settledSource?: string;
settledQuery?: string;
requestedOffset?: number;
cursorPaging?: boolean;
hasMore?: boolean;
/** The source id the SDK uses for "no source given" (shown as 未识别来源). */
unattributedSource?: string;
onQuery(value: string): void;
onSource(value: string): void;
resetPage(): void;
onPage(offset: number): void;
onMore?(): void;
onRetry(): void;
};
};
@@ -684,6 +687,9 @@ export function MemoryMap(props: Props) {
setGroupKey(key);
}
function pickNode(node: MemoryGraphNode) {
// Topics are graph annotations, not detail-bearing records. They remain
// hoverable in the canvas but must not replace the memory inspector.
if (node.node_type === "topic") return;
if (node.memory_id) {
pick(node.memory_id);
return;
@@ -708,11 +714,6 @@ export function MemoryMap(props: Props) {
if (first) pick(first, false);
}
}
if (node.node_type === "topic") {
if (props.simplified) return;
setAxis("topic");
setGroupKey(`topic:${node.label}`);
}
}
const label =
group?.label ??
@@ -1133,62 +1134,81 @@ export function MemoryMap(props: Props) {
aria-busy={pageTransitioning || undefined}
className="map-pagination"
>
<button
type="button"
className="quiet map-tool"
aria-label={text("上一页记忆")}
title={text("上一页记忆")}
disabled={pageTransitioning || library.page.offset === 0}
onClick={() => {
if (axis === "topic") setGroupKey("");
if (library.page) {
const offset = library.page.offset - library.page.limit;
setPageRetrying(false);
pageRetrySawLoading.current = false;
setPendingPageOffset(offset);
library.onPage(offset);
}
}}
>
<ChevronLeft size={16} aria-hidden="true" />
</button>
<span>
{text(
"共 {count} 条,第 {page} / {pages} 页",
{
count: count(library.page.total),
page:
library.page.total === 0
? 0
: Math.floor(library.page.offset / library.page.limit) +
1,
pages: Math.ceil(library.page.total / library.page.limit),
},
library.page.total,
)}
</span>
<button
type="button"
className="quiet map-tool"
aria-label={text("下一页记忆")}
title={text("下一页记忆")}
disabled={
pageTransitioning ||
library.page.offset + library.page.limit >= library.page.total
}
onClick={() => {
if (axis === "topic") setGroupKey("");
if (library.page) {
const offset = library.page.offset + library.page.limit;
setPageRetrying(false);
pageRetrySawLoading.current = false;
setPendingPageOffset(offset);
library.onPage(offset);
}
}}
>
<ChevronRight size={16} aria-hidden="true" />
</button>
{library.cursorPaging ? (
library.hasMore ? (
<R6Button
disabled={pageTransitioning}
onClick={() => library.onMore?.()}
>
{pageTransitioning ? text("正在读取…") : text("加载更多")}
</R6Button>
) : (
<span>{text("已加载全部")}</span>
)
) : (
<>
<button
type="button"
className="quiet map-tool"
aria-label={text("上一页记忆")}
title={text("上一页记忆")}
disabled={pageTransitioning || library.page.offset === 0}
onClick={() => {
if (axis === "topic") setGroupKey("");
if (library.page) {
const offset = library.page.offset - library.page.limit;
setPageRetrying(false);
pageRetrySawLoading.current = false;
setPendingPageOffset(offset);
library.onPage(offset);
}
}}
>
<ChevronLeft size={16} aria-hidden="true" />
</button>
<span>
{text(
"共 {count} 条,第 {page} / {pages} 页",
{
count: count(library.page.total),
page:
library.page.total === 0
? 0
: Math.floor(
library.page.offset / library.page.limit,
) + 1,
pages: Math.ceil(
library.page.total / library.page.limit,
),
},
library.page.total,
)}
</span>
<button
type="button"
className="quiet map-tool"
aria-label={text("下一页记忆")}
title={text("下一页记忆")}
disabled={
pageTransitioning ||
library.page.offset + library.page.limit >=
library.page.total
}
onClick={() => {
if (axis === "topic") setGroupKey("");
if (library.page) {
const offset = library.page.offset + library.page.limit;
setPageRetrying(false);
pageRetrySawLoading.current = false;
setPendingPageOffset(offset);
library.onPage(offset);
}
}}
>
<ChevronRight size={16} aria-hidden="true" />
</button>
</>
)}
</nav>
)}
{!library && loaded && !filtered.length && (search || groupKey) && (
+172 -49
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(
@@ -136,9 +115,7 @@ it("confirms the data effects before switching to lite", async () => {
expect(screen.getByText("搜索范围")).toBeTruthy();
expect(setMemoryMode).not.toHaveBeenCalled();
await user.click(
screen.getByRole("button", { name: "切换到基础记忆(lite)" }),
);
await user.click(screen.getByRole("button", { name: "确认选择" }));
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("lite"));
await waitFor(() =>
expect(document.activeElement).toBe(
@@ -160,7 +137,7 @@ it("traps modal focus, closes on Escape and restores the mode-card focus", async
expect(outside.inert).toBe(true);
await user.keyboard("{Shift>}{Tab}{/Shift}");
expect(document.activeElement).toBe(
screen.getByRole("button", { name: "切换到基础记忆(lite)" }),
screen.getByRole("button", { name: "确认选择" }),
);
await user.keyboard("{Escape}");
@@ -176,9 +153,7 @@ it("keeps focus inside the modal while the mode change is pending", async () =>
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /基础记忆(lite)/);
await user.click(
screen.getByRole("button", { name: "切换到基础记忆(lite)" }),
);
await user.click(screen.getByRole("button", { name: "确认选择" }));
const modal = screen.getByRole("dialog");
await waitFor(() => expect(document.activeElement).toBe(modal));
@@ -227,6 +202,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();
@@ -236,9 +225,11 @@ 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.getByText(/已经处理完的旧对话不会补做/)).toBeTruthy();
expect(screen.getByText(/切换本身不会立即整合/)).toBeTruthy();
expect(screen.getByText("知识图谱")).toBeTruthy();
expect(screen.getAllByText("已有数据")).toHaveLength(1);
expect(screen.getByText(/选择模式本身不会立即整合/)).toBeTruthy();
});
it("distinguishes regular memories from higher-level Ultra data when switching to pro", async () => {
@@ -248,9 +239,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 () => {
@@ -266,9 +275,7 @@ it("focuses the Evolve settings after switching to ultra", async () => {
vi.mocked(getMemoryMode).mockResolvedValue(state("ultra"));
await chooseMode(user, /深度记忆(ultra)/);
await user.click(
screen.getByRole("button", { name: "切换到深度记忆(ultra)" }),
);
await user.click(screen.getByRole("button", { name: "确认选择" }));
await waitFor(() =>
expect(document.activeElement?.getAttribute("id")).toContain("-evolve"),
@@ -287,13 +294,131 @@ it("shows the default nightly evolve controls in ultra", async () => {
"aria-checked",
),
).toBe("true");
expect((screen.getByLabelText("执行时间") as HTMLInputElement).value).toBe(
"03:00",
);
const timePicker = screen.getByRole("button", {
name: "执行时间,03:00",
});
expect(timePicker.tagName).toBe("BUTTON");
expect(timePicker.textContent).toContain("03:00");
expect(screen.getByText(/只运行这一次,不改变每天的计划/)).toBeTruthy();
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
});
it("opens the time picker from the whole control and saves a complete time", async () => {
prepare("ultra");
vi.mocked(setEvolveSchedule).mockResolvedValue({
enabled: true,
time: "04:30",
});
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const trigger = await screen.findByRole("button", {
name: "执行时间,03:00",
});
await user.click(trigger);
const picker = screen.getByRole("dialog", { name: "执行时间" });
await user.selectOptions(
within(picker).getByRole("listbox", { name: "执行时间 HH" }),
"04",
);
await user.selectOptions(
within(picker).getByRole("listbox", { name: "执行时间 MM" }),
"30",
);
expect(setEvolveSchedule).not.toHaveBeenCalled();
await user.click(within(picker).getByRole("button", { name: "保存修改" }));
await waitFor(() =>
expect(setEvolveSchedule).toHaveBeenCalledExactlyOnceWith({
time: "04:30",
}),
);
await waitFor(() =>
expect(screen.getByRole("button", { name: "执行时间,04:30" })).toBe(
document.activeElement,
),
);
expect(screen.queryByRole("dialog", { name: "执行时间" })).toBeNull();
});
it("keeps Tab inside the time picker and restores focus on Escape", async () => {
prepare("ultra");
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const trigger = await screen.findByRole("button", {
name: "执行时间,03:00",
});
await user.click(trigger);
const picker = screen.getByRole("dialog", { name: "执行时间" });
const hours = within(picker).getByRole("listbox", {
name: "执行时间 HH",
});
const save = within(picker).getByRole("button", { name: "保存修改" });
expect(document.activeElement).toBe(hours);
await user.keyboard("{Shift>}{Tab}{/Shift}");
expect(document.activeElement).toBe(save);
await user.keyboard("{Tab}");
expect(document.activeElement).toBe(hours);
await user.keyboard("{Escape}");
expect(screen.queryByRole("dialog", { name: "执行时间" })).toBeNull();
expect(document.activeElement).toBe(trigger);
});
it("keeps the selected time open for retry when saving fails", async () => {
prepare("ultra");
vi.mocked(setEvolveSchedule).mockRejectedValue(new Error("offline"));
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await user.click(
await screen.findByRole("button", { name: "执行时间,03:00" }),
);
const picker = screen.getByRole("dialog", { name: "执行时间" });
const minutes = within(picker).getByRole("listbox", {
name: "执行时间 MM",
});
await user.selectOptions(minutes, "30");
await user.click(within(picker).getByRole("button", { name: "保存修改" }));
expect(
await screen.findByText("自动整合设置未能保存,请重试。"),
).toBeTruthy();
expect(screen.getByRole("dialog", { name: "执行时间" })).toBeTruthy();
expect((minutes as HTMLSelectElement).value).toBe("30");
await waitFor(() =>
expect(document.activeElement).toBe(
within(picker).getByRole("button", { name: "保存修改" }),
),
);
});
it("keeps the time dialog and focus stable while saving is pending", async () => {
prepare("ultra");
vi.mocked(setEvolveSchedule).mockReturnValue(new Promise(() => {}));
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await user.click(
await screen.findByRole("button", { name: "执行时间,03:00" }),
);
const picker = screen.getByRole("dialog", { name: "执行时间" });
await user.selectOptions(
within(picker).getByRole("listbox", { name: "执行时间 MM" }),
"30",
);
await user.click(within(picker).getByRole("button", { name: "保存修改" }));
await waitFor(() => expect(setEvolveSchedule).toHaveBeenCalledTimes(1));
expect(document.activeElement).toBe(picker);
await user.keyboard("{Escape}");
expect(screen.getByRole("dialog", { name: "执行时间" })).toBe(picker);
expect(document.activeElement).toBe(picker);
});
it("hides the time field when automatic evolve is off and keeps the saved time visible", async () => {
prepare("ultra");
vi.mocked(setEvolveSchedule).mockResolvedValue({
@@ -309,7 +434,7 @@ it("hides the time field when automatic evolve is off and keeps the saved time v
await user.click(automatic);
await screen.findByText(/重新开启后仍按 03:00 运行/);
expect(screen.queryByLabelText("执行时间")).toBeNull();
expect(screen.queryByRole("button", { name: /执行时间/ })).toBeNull();
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
});
@@ -367,14 +492,12 @@ it("keeps a failed switch explanation inside the open confirmation dialog", asyn
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /深度记忆(ultra)/);
await user.click(
screen.getByRole("button", { name: "切换到深度记忆(ultra)" }),
);
await user.click(screen.getByRole("button", { name: "确认选择" }));
const dialog = screen.getByRole("dialog");
expect(
await within(dialog).findByText(
"当前安装缺少知识图谱组件,无法开启 ultra。",
"当前安装缺少知识图谱组件,无法选择 ultra。",
),
).toBeTruthy();
});
@@ -410,7 +533,7 @@ it("blocks mode and Evolve controls during embedding maintenance", async () => {
expect(
await screen.findByText(
"正在检查或切换向量模型,完成前不能切换模式或手动整合。",
"正在检查或切换向量模型,完成前不能选择模式或手动整合。",
),
).toBeTruthy();
expect(
+29 -240
View File
@@ -26,93 +26,15 @@ import {
issueConfirmedModeTicket,
} from "./confirmed-memory-mode";
import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
import {
BLOCKER_COPY,
MemoryModeConfirmDialog,
MODE_COPY,
} from "./memory-mode-ui";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button, R6Switch } from "./R6Controls";
import { R6Button } from "./R6Controls";
import { R6StateRecovery } from "./R6StateRecovery";
const MODE_COPY: Record<
MemoryPipelineMode,
{ title: string; description: string }
> = {
lite: {
title: "基础记忆(lite)",
description: "保存原始对话 · 不调用生成模型 · 搜索原始会话",
},
pro: {
title: "智能记忆(pro)",
description: "沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆",
},
ultra: {
title: "深度记忆(ultra)",
description: "智能记忆 + 知识图谱 · 可自动 Evolve · 增强关联召回",
},
};
function planRows(plan: MemoryModePlan) {
const rows = [
{
label: "新对话",
content:
plan.capabilities.content_plane === "raw"
? "之后只保存原始对话,不再生成新记忆。"
: plan.capabilities.graph_enabled
? "新对话会整理成记忆并写入知识图谱。"
: "新对话会由模型整理成记忆。",
},
{
label: "模型调用与费用",
content: plan.capabilities.generation_required
? plan.capabilities.evolve_available
? "会调用当前生成模型;自动整合可能产生额外费用。切换本身不会立即整合。"
: "会调用当前生成模型,可能产生服务商费用。"
: "不调用生成模型,不再产生这部分费用。",
},
{
label: "搜索范围",
content:
plan.capabilities.content_plane === "raw"
? "切换后搜索原始对话。"
: plan.capabilities.graph_enabled
? "搜索沉淀记忆,并用知识图谱补充关联。"
: "搜索沉淀后的记忆。",
},
];
if (plan.effects.includes("earlier_raw_not_extracted"))
rows.push({
label: "已有数据",
content: "已经处理完的旧对话不会补做;原始对话全部保留。",
});
if (plan.effects.includes("higher_mode_memories_hidden"))
rows.push({
label: "已有数据",
content: "已有记忆继续保留,但高级记忆在当前模式下不显示、不参与召回。",
});
if (plan.effects.includes("existing_memories_added_to_graph"))
rows.push({
label: "已有数据",
content: "已有记忆会在后台加入知识图谱,期间仍可使用。",
});
if (plan.effects.includes("evolve_stopped"))
rows.push({
label: "已有数据",
content:
"普通沉淀记忆仍可使用;更高层记忆和图谱数据继续保留,但不参与当前召回;每天自动整合会停止。",
});
if (plan.capabilities.evolve_available)
rows.push({
label: "切换后",
content: "可设置每天自动整合时间,也可以随时立即整合一次。",
});
return rows;
}
const BLOCKER_COPY: Record<MemoryModePlan["blockers"][number], string> = {
active_model_required: "使用 pro 或 ultra 前需要先配置并启用生成模型。",
embedding_maintenance_in_progress:
"正在检查或切换向量模型,完成后才能切换记忆模式。",
memory_lifecycle_busy: "记忆维护正在进行,完成后才能切换模式。",
graph_store_unavailable: "当前安装缺少知识图谱组件,无法开启 ultra。",
};
import { UltraEvolveScheduleFields } from "./UltraEvolveScheduleFields";
export function MemoryModeSettings({
hasActiveModel,
@@ -140,65 +62,9 @@ export function MemoryModeSettings({
const [readFailed, setReadFailed] = useState(false);
const [run, setRun] = useState<EvolveRunReceipt | null>(null);
const refreshVersion = useRef(0);
const dialog = useRef<HTMLElement>(null);
const dialogOpener = useRef<HTMLElement | null>(null);
const focusEvolveWhenReady = useRef(false);
const focusModeWhenReady = useRef<MemoryPipelineMode | null>(null);
const busyRef = useRef(false);
busyRef.current = busy;
useEffect(() => {
if (!pendingMode || !dialog.current) return;
const panel = dialog.current;
const inertSiblings = new Map<HTMLElement, boolean>();
let branch: HTMLElement = panel.parentElement ?? panel;
while (branch.parentElement) {
for (const sibling of branch.parentElement.children) {
if (!(sibling instanceof HTMLElement) || sibling === branch) continue;
inertSiblings.set(sibling, sibling.inert);
sibling.inert = true;
}
branch = branch.parentElement;
if (branch === document.body) break;
}
const controls = () =>
Array.from(panel.querySelectorAll<HTMLElement>("button:not(:disabled)"));
controls()[0]?.focus();
const handleKey = (event: KeyboardEvent) => {
if (event.key === "Escape" && !busyRef.current) {
event.preventDefault();
setPendingPlan(null);
setPendingMode(null);
return;
}
if (event.key !== "Tab") return;
const items = controls();
const first = items[0];
const last = items.at(-1);
if (!first || !last) {
event.preventDefault();
panel.focus();
return;
}
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
panel.addEventListener("keydown", handleKey);
return () => {
panel.removeEventListener("keydown", handleKey);
for (const [sibling, wasInert] of inertSiblings) sibling.inert = wasInert;
if (dialogOpener.current?.isConnected) dialogOpener.current.focus();
};
}, [pendingMode]);
useEffect(() => {
if (pendingMode && busy) dialog.current?.focus();
}, [busy, pendingMode]);
useEffect(() => {
if (!focusEvolveWhenReady.current || pendingMode || state?.mode !== "ultra")
@@ -330,8 +196,10 @@ export function MemoryModeSettings({
setError("");
try {
setScheduleState(await setEvolveSchedule(update));
return true;
} catch {
setError("自动整合设置未能保存,请重试。");
return false;
} finally {
setBusy(false);
}
@@ -424,47 +292,13 @@ export function MemoryModeSettings({
aria-labelledby={`${id}-evolve-title`}
tabIndex={-1}
>
<div className="r6-evolve-heading">
<h3 id={`${id}-evolve-title`}>{text("记忆整合(Evolve)")}</h3>
<p>{text("定期重新整理已有记忆,补充更稳定的关联和结论。")}</p>
</div>
<div className="r6-evolve-setting">
<div className="r6-evolve-setting-copy">
<h3>{text("每天自动整合")}</h3>
<p>
{text(
schedule.enabled
? "每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。"
: "已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。",
).replace("{time}", schedule.time)}
</p>
</div>
<R6Switch
label={text("每天自动整合")}
checked={schedule.enabled}
disabled={busy || maintenance}
onCheckedChange={(enabled) => void changeSchedule({ enabled })}
/>
{schedule.enabled ? (
<div className="r6-evolve-time">
<div>
<label htmlFor={`${id}-evolve-time`}>
{text("执行时间")}
</label>
<p>{text("使用这台设备的本地时间。")}</p>
</div>
<input
id={`${id}-evolve-time`}
type="time"
value={schedule.time}
disabled={busy || maintenance}
onChange={(event) =>
void changeSchedule({ time: event.target.value })
}
/>
</div>
) : null}
</div>
<UltraEvolveScheduleFields
schedule={schedule}
headingId={`${id}-evolve-title`}
disabled={busy || maintenance}
onEnabledChange={(enabled) => void changeSchedule({ enabled })}
onTimeChange={(time) => changeSchedule({ time })}
/>
<div className="r6-evolve-setting r6-evolve-setting--manual">
<div className="r6-evolve-setting-copy">
<h3>{text("立即整合一次")}</h3>
@@ -494,64 +328,19 @@ export function MemoryModeSettings({
</section>
) : null}
{pendingMode ? (
<div className="r6-settings-dialog-layer" role="presentation">
<section
ref={dialog}
className="r6-settings-dialog r6-mode-confirm-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="memory-mode-confirm-title"
tabIndex={-1}
>
<h2 id="memory-mode-confirm-title">
{text("切换到 {mode}?").replace(
"{mode}",
text(MODE_COPY[pendingMode].title),
)}
</h2>
<dl className="r6-mode-confirm-effects">
{(pendingPlan ? planRows(pendingPlan) : []).map((row) => (
<div key={`${row.label}:${row.content}`}>
<dt>{text(row.label)}</dt>
<dd>{text(row.content)}</dd>
</div>
))}
</dl>
{(pendingPlan?.blockers ?? []).map((blocker) => (
<p className="r6-settings-error" role="alert" key={blocker}>
{text(BLOCKER_COPY[blocker])}
</p>
))}
{error ? (
<p className="r6-settings-error" role="alert">
{text(error)}
</p>
) : null}
<footer>
<R6Button
disabled={busy}
onClick={() => {
setPendingPlan(null);
setPendingMode(null);
}}
>
{text("取消")}
</R6Button>
<R6Button
variant="primary"
pending={busy}
disabled={Boolean(pendingPlan?.blockers.length)}
onClick={() => void commitMode()}
>
{text("切换到 {mode}").replace(
"{mode}",
text(MODE_COPY[pendingMode].title),
)}
</R6Button>
</footer>
</section>
</div>
{pendingMode && pendingPlan ? (
<MemoryModeConfirmDialog
mode={pendingMode}
plan={pendingPlan}
busy={busy}
error={error}
opener={dialogOpener.current}
onCancel={() => {
setPendingPlan(null);
setPendingMode(null);
}}
onConfirm={() => void commitMode()}
/>
) : null}
</section>
);
+117 -13
View File
@@ -320,9 +320,9 @@ it("pages and filters the raw conversation data plane in lite", async () => {
setup();
reads.processing.mockResolvedValue(modeProcessing("lite"));
reads.raw.mockImplementation(async (options = {}) => {
const offset = "offset" in options ? (options.offset ?? 0) : 0;
const cursor = "cursor" in options ? options.cursor : null;
const source = "source" in options ? options.source : undefined;
const rawId = `${source ?? "all"}-${offset}`;
const rawId = `${source ?? "all"}-${cursor ? "more" : "first"}`;
return {
items: [
{
@@ -333,14 +333,17 @@ it("pages and filters the raw conversation data plane in lite", async () => {
source: source ?? "cursor",
session_id: `session-${rawId}`,
memory_at: null,
gmt_created: 1_761_000_000 - offset,
gmt_created: 1_761_000_000,
custom: {},
messages: [{ role: "user", content: `原始会话 ${rawId}` }],
},
],
total: source ? 1 : 21,
total: cursor ? 2 : 1,
limit: 20,
offset,
offset: cursor ? 1 : 0,
next_cursor: !source && !cursor ? "turn:1:0:1" : null,
has_more: !source && !cursor,
total_exact: false,
};
});
const user = userEvent.setup();
@@ -359,34 +362,108 @@ it("pages and filters the raw conversation data plane in lite", async () => {
/>,
);
expect(await screen.findAllByText("原始会话 all-0")).toHaveLength(2);
await user.click(screen.getByRole("button", { name: "下一页记忆" }));
expect(await screen.findAllByText("原始会话 all-20")).toHaveLength(2);
expect(await screen.findAllByText("原始会话 all-first")).toHaveLength(2);
await user.click(screen.getByRole("button", { name: "加载更多" }));
expect(await screen.findAllByText("原始会话 all-more")).toHaveLength(1);
expect(screen.getAllByText("原始会话 all-first")).toHaveLength(2);
expect(reads.raw).toHaveBeenLastCalledWith(
expect.objectContaining({ source: undefined, offset: 20, limit: 20 }),
expect.objectContaining({
source: undefined,
cursor: "turn:1:0:1",
paging: "cursor",
limit: 20,
}),
);
await user.click(screen.getByRole("combobox", { name: "来源" }));
await user.click(screen.getByRole("option", { name: "Cursor" }));
expect(await screen.findAllByText("原始会话 cursor-0")).toHaveLength(2);
expect(await screen.findAllByText("原始会话 cursor-first")).toHaveLength(2);
expect(reads.raw).toHaveBeenLastCalledWith(
expect.objectContaining({ source: "cursor", offset: 0, limit: 20 }),
expect.objectContaining({
source: "cursor",
cursor: null,
paging: "cursor",
limit: 20,
}),
);
await user.click(screen.getByRole("combobox", { name: "来源" }));
await user.click(screen.getByRole("option", { name: "手动添加" }));
expect(
await screen.findAllByText("原始会话 one-memory-manual-0"),
await screen.findAllByText("原始会话 one-memory-manual-first"),
).toHaveLength(2);
expect(reads.raw).toHaveBeenLastCalledWith(
expect.objectContaining({
source: "one-memory-manual",
offset: 0,
limit: 20,
paging: "cursor",
cursor: null,
}),
);
});
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 +559,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 +594,7 @@ it("opens a search hit with the complete role transcript", async () => {
],
},
],
total: 1,
total: 2,
limit: 500,
offset: 0,
}
@@ -506,6 +604,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 +626,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",
+105 -24
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";
@@ -122,6 +123,7 @@ export function MemorySpace({
const selectedRawRef = useRef<RawMapRecord | null>(null);
selectedRawRef.current = selectedRaw;
const rawRecords = useRef(new Map<string, RawMapRecord>());
const rawRecordsScope = useRef("");
const [detailLoad, setDetailLoad] = useState<{
memoryId: string;
status: "loading" | "failed";
@@ -208,6 +210,7 @@ export function MemorySpace({
const [sdkLibraryQuery, setSdkLibraryQuery] = useState("");
const [sdkLibrarySource, setSdkLibrarySource] = useState("");
const [sdkLibraryOffset, setSdkLibraryOffset] = useState(0);
const [sdkRawCursor, setSdkRawCursor] = useState<string | null>(null);
const [processing, setProcessing] = useState<MemoryProcessingSummary | null>(
null,
);
@@ -225,6 +228,10 @@ export function MemorySpace({
evolve_available: pipelineMode === "ultra",
}
: null;
const refreshSdkMap = useCallback(() => {
if (lite) setSdkRawCursor(null);
bumpSdkMap();
}, [bumpSdkMap, lite]);
const requestedQuery = sdkLibraryQuery.trim();
const sdkPageSettled =
sdkLibrary.settledMode === pipelineMode &&
@@ -275,11 +282,18 @@ export function MemorySpace({
if (first) setSdkLibrary((current) => ({ ...current, loading: true }));
try {
const rememberRaw = (items: RawMapRecord[]) => {
const next = new Map<string, RawMapRecord>();
const next =
sdkRawCursor && rawRecordsScope.current === windowKey
? new Map(rawRecords.current)
: 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;
rawRecordsScope.current = windowKey;
};
const [page, graph] = lite
? await Promise.all([
@@ -293,8 +307,10 @@ export function MemorySpace({
})
: getSdkRaw({
source,
offset: sdkLibraryOffset,
offset: 0,
limit: SDK_MEMORY_PAGE_SIZE,
paging: "cursor",
cursor: sdkRawCursor,
signal: request.signal,
}).then((rawPage) => {
rememberRaw(rawPage.items);
@@ -342,17 +358,64 @@ export function MemorySpace({
};
// The window expired: the server searched again and answered page 1.
if (page.window_reset && sdkLibraryOffset !== 0) setSdkLibraryOffset(0);
setSdkLibrary({
page,
loading: false,
failed: false,
settledSource: sdkLibrarySource,
settledQuery: sdkLibraryQuery,
settledMode: pipelineMode,
setSdkLibrary((current) => {
const currentPage = current.page;
const append =
lite &&
sdkRawCursor !== null &&
currentPage !== null &&
current.settledSource === sdkLibrarySource &&
current.settledQuery === sdkLibraryQuery &&
current.settledMode === pipelineMode;
if (!append)
return {
page,
loading: false,
failed: false,
settledSource: sdkLibrarySource,
settledQuery: sdkLibraryQuery,
settledMode: pipelineMode,
};
const items = [...currentPage.items];
const known = new Set(items.map((item) => item.memory_id));
for (const item of page.items)
if (!known.has(item.memory_id)) {
known.add(item.memory_id);
items.push(item);
}
return {
page: {
...page,
items,
total: items.length + (page.has_more ? 1 : 0),
offset: 0,
source_ids_by_memory: {
...currentPage.source_ids_by_memory,
...page.source_ids_by_memory,
},
source_time_by_memory: {
...currentPage.source_time_by_memory,
...page.source_time_by_memory,
},
},
loading: false,
failed: false,
settledSource: sdkLibrarySource,
settledQuery: sdkLibraryQuery,
settledMode: pipelineMode,
};
});
setSdkGraph({ scope: windowKey, ...graph });
} catch {
} catch (reason) {
if (cancelled || request.signal.aborted) return;
if (
lite &&
sdkRawCursor !== null &&
apiErrorCode(reason) === "raw_cursor_stale"
) {
setSdkRawCursor(null);
return;
}
setSdkLibrary((current) => ({
...current,
loading: false,
@@ -361,10 +424,13 @@ export function MemorySpace({
}
};
void read(true);
const timer = setInterval(() => void read(false), 30_000);
const timer =
lite && sdkRawCursor !== null
? undefined
: setInterval(() => void read(false), 30_000);
return () => {
cancelled = true;
clearInterval(timer);
if (timer !== undefined) clearInterval(timer);
controller?.abort();
};
}, [
@@ -373,6 +439,7 @@ export function MemorySpace({
sdkLibraryOffset,
sdkLibraryQuery,
sdkLibrarySource,
sdkRawCursor,
lite,
pipelineMode,
]);
@@ -521,6 +588,7 @@ export function MemorySpace({
rawRecords.current.clear();
sdkSearchWindow.current = { key: "", id: null };
setSdkLibraryOffset(0);
setSdkRawCursor(null);
setSdkLibraryQuery("");
setSdkGraph(null);
setSdkLibrary((current) => ({
@@ -717,7 +785,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);
@@ -798,7 +868,7 @@ export function MemorySpace({
);
trace.done("succeeded");
closeDetail();
bumpSdkMap();
refreshSdkMap();
} catch (reason) {
trace.done(writeTraceStatus(reason), {
reason: "memory_action_failed",
@@ -827,7 +897,7 @@ export function MemorySpace({
trace.done("succeeded");
setDetail(sdkDetail(saved));
setDraft(sdkEditableText(saved));
bumpSdkMap();
refreshSdkMap();
} catch (reason) {
trace.done(writeTraceStatus(reason), {
reason: isDefiniteRefusal(reason)
@@ -892,10 +962,12 @@ export function MemorySpace({
? t("memory:记忆暂时无法读取")
: lite
? sdkPage
? `${t("memory:原始对话")} · ${tn(
"sdk.raw.found",
sdkPage.total,
)}`
? sdkPage.total_exact === false
? t("memory:原始对话")
: `${t("memory:原始对话")} · ${tn(
"sdk.raw.found",
sdkPage.total,
)}`
: sdkLibrary.failed
? null
: t("memory:正在读取…")
@@ -928,7 +1000,7 @@ export function MemorySpace({
onSaved={() => {
// A finished addition is new page content: re-read now rather than
// leave it for the next poll.
bumpSdkMap();
refreshSdkMap();
}}
onOpenMemory={(id) => {
if (!simplified) setMode("library");
@@ -1195,12 +1267,21 @@ export function MemorySpace({
settledSource: sdkLibrary.settledSource,
settledQuery: sdkLibrary.settledQuery,
requestedOffset: sdkLibraryOffset,
cursorPaging: lite && !sdkLibraryQuery.trim(),
hasMore: !!sdkPage?.has_more && !!sdkPage?.next_cursor,
unattributedSource: UNATTRIBUTED_SOURCE_ID,
onQuery: setSdkLibraryQuery,
onSource: setSdkLibrarySource,
resetPage: () => setSdkLibraryOffset(0),
resetPage: () => {
setSdkLibraryOffset(0);
setSdkRawCursor(null);
},
onPage: (offset) => setSdkLibraryOffset(offset),
onRetry: bumpSdkMap,
onMore: () => {
if (sdkPage?.next_cursor)
setSdkRawCursor(sdkPage.next_cursor);
},
onRetry: refreshSdkMap,
}
: undefined
}
+15 -7
View File
@@ -1482,7 +1482,12 @@ describe("ModelSettings", () => {
...profile,
profile_id: "created",
revision: 1,
capabilities,
capabilities: {
...capabilities,
endpoint_reachable: false,
model_available: false,
embedding_available: false,
},
};
profiles = [];
create.mockImplementation(async () => {
@@ -1491,11 +1496,17 @@ describe("ModelSettings", () => {
});
probeProfile.mockResolvedValue({
profile_id: created.profile_id,
profile_revision: created.revision,
input_profile_revision: created.revision,
profile_revision: 2,
stages: [],
capabilities,
});
activate.mockResolvedValue({ ...created, is_active: true });
activate.mockResolvedValue({
...created,
revision: 2,
capabilities,
is_active: true,
});
render(<ModelSettings compactSingle onSetupComplete={onSetupComplete} />);
await user.type(screen.getByLabelText("配置名称"), "Current model");
@@ -1504,10 +1515,7 @@ describe("ModelSettings", () => {
await user.click(screen.getByRole("button", { name: "测试并完成配置" }));
await waitFor(() => expect(onSetupComplete).toHaveBeenCalledOnce());
expect(planActivation).toHaveBeenCalledWith(
created.profile_id,
created.revision,
);
expect(planActivation).toHaveBeenCalledWith(created.profile_id, 2);
expect(activate).toHaveBeenCalledWith(created.profile_id);
});
+15 -50
View File
@@ -27,6 +27,7 @@ import {
presetForGeneration,
validateGenerationModel,
} from "./GenerationModelPrimitives";
import { modelProfileReady, profileFromProbe } from "./model-profile-readiness";
import { OptionMenu } from "./OptionMenu";
import { useOnboardingTranslation } from "./onboarding-language";
import { PROVIDER_PRESETS } from "./provider-presets";
@@ -174,27 +175,6 @@ function validEndpoint(value: string): boolean {
}
}
function ready(
profile: ModelProfile,
probe: ModelProbeResult | null = null,
error: string | null = null,
): boolean {
return (
!error &&
(!probe ||
(probe.profile_id === profile.profile_id &&
probe.profile_revision === profile.revision &&
probe.capabilities.endpoint_reachable &&
probe.capabilities.model_available &&
probe.capabilities.embedding_available)) &&
profile.credential_state !== "unavailable" &&
!(profile.has_configured_api_key && !profile.has_api_key) &&
profile.capabilities.endpoint_reachable &&
profile.capabilities.model_available &&
profile.capabilities.embedding_available
);
}
function endpointLabel(value: string | null): string {
if (!value) return "未提供接口地址";
try {
@@ -467,7 +447,11 @@ export function ModelSettings({
technicalTouched.current.clear();
};
const isReady = (profile: ModelProfile) =>
ready(profile, models.probeFor(profile), models.probeErrorFor(profile));
modelProfileReady(
profile,
models.probeFor(profile),
models.probeErrorFor(profile),
);
function editProfile(profile: ModelProfile) {
if (ownerLocked) return;
@@ -626,7 +610,7 @@ export function ModelSettings({
!active ||
active.profile_id !== profile.profile_id ||
!active.is_active ||
!ready(active, result)
!modelProfileReady(active, result)
) {
setSetupError("配置已保存,尚未确认启用成功。请核对配置后重试。");
return false;
@@ -640,38 +624,15 @@ export function ModelSettings({
setSetupPhase("testing");
const result = await models.probeProfile(profile.profile_id);
if (!alive.current) return false;
if (
result &&
result.profile_id === profile.profile_id &&
result.profile_revision !== profile.revision
) {
// A newer revision used to be accepted here, and then activation turned
// on whatever the configuration had become. The user is told the
// settings they filled in passed, so this hands them a model they never
// tested. `ready` below cannot catch it either: by then the probe and
// the activated profile are both on the new revision and agree.
setSetupError(
result.profile_revision > profile.revision
? "这份配置在测试期间被改过,刚才测的是改动后的版本。配置已保存但未启用,请重新测试并启用。"
: "测试结果来自这份配置更早的版本。配置已保存但未启用,请重新测试并启用。",
);
return false;
}
if (
!result ||
result.profile_id !== profile.profile_id ||
result.profile_revision !== profile.revision ||
!result.capabilities.endpoint_reachable ||
!result.capabilities.model_available ||
!result.capabilities.embedding_available
) {
const testedProfile = profileFromProbe(profile, result);
if (!testedProfile || !modelProfileReady(testedProfile, result ?? null)) {
setSetupError(
"配置已保存,连接测试未通过。请核对配置后重试,历史对话尚未发送。",
);
setFields(probeFieldErrors(result));
return false;
}
return activateWithPlan(profile, result);
return activateWithPlan(testedProfile, result ?? null);
}
async function activateTested(profile: ModelProfile) {
@@ -1940,7 +1901,11 @@ function PresetProfileCard({
</button>
<button
type="button"
disabled={busy || !canEnable || !ready(profile, probe, probeError)}
disabled={
busy ||
!canEnable ||
!modelProfileReady(profile, probe, probeError)
}
onClick={onActivate}
>
{text("设为当前模型")}
@@ -10,13 +10,33 @@ import type { ImportSourcesController } from "./use-import-sources";
vi.mock("./api", () => ({
completeOnboarding: vi.fn(),
getEvolveSchedule: vi.fn().mockResolvedValue({
enabled: true,
time: "03:00",
}),
getModelProfiles: vi.fn().mockResolvedValue([]),
setEvolveSchedule: vi.fn(),
memoryModePlanUnsupported: vi.fn(() => false),
getMemoryMode: vi.fn().mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: false,
}),
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
planMemoryMode: vi.fn().mockResolvedValue({ blockers: [] }),
planMemoryMode: vi.fn().mockResolvedValue({
current: "lite",
target: "pro",
effective_target: "pro",
capabilities: {
content_plane: "memory",
generation_required: true,
graph_enabled: false,
evolve_available: false,
},
effects: [],
warnings: [],
blockers: ["active_model_required"],
}),
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
}));
@@ -136,6 +156,9 @@ it("moves from import to the actual third step without completing onboarding", a
);
await userEvent.click(screen.getByRole("button", { name: "进入第三步" }));
await userEvent.click(screen.getByRole("radio", { name: /pro/i }));
await userEvent.click(
await screen.findByRole("button", { name: "确认选择" }),
);
await userEvent.click(screen.getByRole("button", { name: "继续配置模型" }));
expect(screen.getByRole("heading", { name: "第三步模型页面" })).toBeTruthy();
expect(completeOnboarding).not.toHaveBeenCalled();
@@ -165,6 +188,9 @@ it("lets an empty first-time user enter without granting or importing a source",
await waitFor(() => expect((skip as HTMLButtonElement).disabled).toBe(false));
await userEvent.click(skip);
await userEvent.click(screen.getByRole("radio", { name: /lite/i }));
await userEvent.click(
await screen.findByRole("button", { name: "确认选择" }),
);
await userEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
@@ -188,6 +214,9 @@ it("retains explicit retry or enter when saving the skipped onboarding fails", a
await waitFor(() => expect((skip as HTMLButtonElement).disabled).toBe(false));
await userEvent.click(skip);
await userEvent.click(screen.getByRole("radio", { name: /lite/i }));
await userEvent.click(
await screen.findByRole("button", { name: "确认选择" }),
);
await userEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
await screen.findByRole("button", { name: "重试保存并进入" });
expect(finish).not.toHaveBeenCalled();
@@ -24,6 +24,12 @@ import type { ImportSourcesController } from "./use-import-sources";
vi.mock("./api", () => ({
completeOnboarding: vi.fn(),
getEvolveSchedule: vi.fn().mockResolvedValue({
enabled: true,
time: "03:00",
}),
setEvolveSchedule: vi.fn(),
memoryModePlanUnsupported: vi.fn(() => false),
getModelProfiles: vi.fn(),
getMemoryMode: vi.fn().mockResolvedValue({
mode: "pro",
@@ -31,7 +37,20 @@ vi.mock("./api", () => ({
explicitly_configured: false,
}),
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
planMemoryMode: vi.fn().mockResolvedValue({ blockers: [] }),
planMemoryMode: vi.fn().mockResolvedValue({
current: "pro",
target: "lite",
effective_target: "lite",
capabilities: {
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
},
effects: [],
warnings: [],
blockers: [],
}),
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
}));
@@ -422,6 +441,9 @@ it("translates completion recovery live without repeating completion writes", as
screen.getByRole("button", { name: "Entrar y añadir más tarde" }),
);
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
fireEvent.click(
await screen.findByRole("button", { name: "Confirmar selección" }),
);
fireEvent.click(screen.getByRole("button", { name: "Entrar con Lite" }));
expect(
await screen.findByRole("button", { name: "Reintentar guardar y entrar" }),
@@ -26,13 +26,32 @@ vi.mock("./api", () => ({
getImportJobs: vi.fn(),
connectSources: vi.fn(),
completeOnboarding: vi.fn(),
getEvolveSchedule: vi.fn().mockResolvedValue({
enabled: true,
time: "03:00",
}),
setEvolveSchedule: vi.fn(),
memoryModePlanUnsupported: vi.fn(() => false),
getMemoryMode: vi.fn().mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: false,
}),
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
planMemoryMode: vi.fn().mockResolvedValue({ blockers: [] }),
planMemoryMode: vi.fn().mockResolvedValue({
current: "pro",
target: "lite",
effective_target: "lite",
capabilities: {
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
},
effects: [],
warnings: [],
blockers: [],
}),
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
getSourceConnection: vi.fn(),
@@ -159,8 +178,23 @@ beforeEach(() => {
} as never);
vi.mocked(api.setMemoryMode).mockResolvedValue({ mode: "pro" } as never);
vi.mocked(api.planMemoryMode).mockResolvedValue({
current: "pro",
target: "lite",
effective_target: "lite",
capabilities: {
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
},
effects: [],
warnings: [],
blockers: [],
} as never);
vi.mocked(api.getEvolveSchedule).mockResolvedValue({
enabled: true,
time: "03:00",
});
vi.mocked(api.putOnboardingProgress).mockResolvedValue(state);
vi.mocked(api.patchOnboardingProgress).mockResolvedValue(state);
localStorage.clear();
@@ -319,6 +353,7 @@ it("empty discovery shows zero and allows explicit entry without a connection re
);
fireEvent.click(screen.getByRole("button", { name: "稍后添加,先进入" }));
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
fireEvent.click(await screen.findByRole("button", { name: "确认选择" }));
fireEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
@@ -371,6 +406,7 @@ it("partial acceptance preserves the successful job and failure through model-st
expect(api.completeOnboarding).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "进入应用" }));
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
fireEvent.click(await screen.findByRole("button", { name: "确认选择" }));
fireEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
@@ -15,6 +15,12 @@ import type { ImportSourcesController } from "./use-import-sources";
vi.mock("./api", () => ({
completeOnboarding: vi.fn(),
getEvolveSchedule: vi.fn().mockResolvedValue({
enabled: true,
time: "03:00",
}),
setEvolveSchedule: vi.fn(),
memoryModePlanUnsupported: vi.fn(() => false),
getMemoryMode: vi.fn().mockResolvedValue({
mode: "pro",
configured_mode: "pro",
+636 -31
View File
@@ -40,6 +40,7 @@ const getMemoryProcessing = vi.fn();
const previewSourceDirectory = vi.fn();
const saveSourceDirectory = vi.fn();
const getSourceImportReceipt = vi.fn();
let modelActive = false;
vi.mock("./api", () => ({
getHealth: vi.fn(),
@@ -52,6 +53,15 @@ vi.mock("./api", () => ({
setSourceSync: vi.fn(),
changeImportJob: vi.fn(),
getImportJob: vi.fn(),
getEvolveSchedule: vi.fn().mockResolvedValue({
enabled: true,
time: "03:00",
}),
setEvolveSchedule: vi.fn().mockResolvedValue({
enabled: true,
time: "03:00",
}),
memoryModePlanUnsupported: vi.fn(() => false),
apiErrorCode: vi.fn(() => null),
completeOnboarding: (...args: unknown[]) => completeOnboarding(...args),
setMemoryMode: (...args: unknown[]) => setMemoryMode(...args),
@@ -91,6 +101,8 @@ vi.mock("./OnboardingModelController", () => ({
<button
type="button"
onClick={() => {
modelActive = true;
getModelProfiles.mockResolvedValue([{ is_active: true }]);
onProfilePersisted("9be6d465-4cf2-4c72-93f7-15f3c012bc80");
onModelActiveConfirmed(
generationToken,
@@ -100,6 +112,20 @@ vi.mock("./OnboardingModelController", () => ({
>
模型启用回执已确认
</button>
<button
type="button"
onClick={() => {
modelActive = true;
getModelProfiles.mockResolvedValue([{ is_active: true }]);
onProfilePersisted("8be6d465-4cf2-4c72-93f7-15f3c012bc80");
onModelActiveConfirmed(
generationToken,
"8be6d465-4cf2-4c72-93f7-15f3c012bc80",
);
}}
>
第二个模型启用回执已确认
</button>
<button type="button" onClick={onSkip ?? onBack}>
跳过模型进入应用
</button>
@@ -213,13 +239,20 @@ async function continueImport(user: ReturnType<typeof userEvent.setup>) {
}
async function openModelStep(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole("button", { name: "选择记忆模式" }));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
}
async function chooseLiteAndContinue(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole("radio", { name: /lite/i }));
await selectMode(user, /lite/i);
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
}
async function selectMode(
user: ReturnType<typeof userEvent.setup>,
name: RegExp,
) {
await user.click(screen.getByRole("radio", { name }));
await user.click(await screen.findByRole("button", { name: "确认选择" }));
}
function choice(name = "Claude Code") {
return screen.getByRole<HTMLInputElement>("checkbox", {
name: `导入 ${name} 历史`,
@@ -253,6 +286,7 @@ async function approve(user: ReturnType<typeof userEvent.setup>) {
describe("OnboardingFlow S1.3", () => {
beforeEach(() => {
vi.resetAllMocks();
modelActive = false;
resetConfirmedMemoryModeForTests();
window.localStorage.clear();
window.oneMemory = undefined;
@@ -261,15 +295,16 @@ describe("OnboardingFlow S1.3", () => {
status: "completed",
});
setMemoryMode.mockResolvedValue({ mode: "pro" });
planMemoryMode.mockResolvedValue({
planMemoryMode.mockImplementation(async (target) => ({
current: "lite",
target: "pro",
effective_target: "pro",
target,
effective_target: target,
capabilities: {},
effects: [],
warnings: [],
blockers: [],
});
blockers:
target !== "lite" && !modelActive ? ["active_model_required"] : [],
}));
putOnboardingProgress.mockImplementation(
async (update: Partial<OnboardingState>) => ({
...onboarding,
@@ -287,6 +322,7 @@ describe("OnboardingFlow S1.3", () => {
configured_mode: "pro",
explicitly_configured: false,
});
getModelProfiles.mockResolvedValue([]);
});
afterEach(() => {
cleanup();
@@ -445,6 +481,155 @@ describe("OnboardingFlow S1.3", () => {
).toBeTruthy();
});
it("accepts Lite once while the import submission is still in flight", async () => {
const user = userEvent.setup();
let resolveImport!: (
value: Awaited<
ReturnType<ImportSourcesController["connectForOnboarding"]>
>,
) => void;
const controller = imports({
connectForOnboarding: vi.fn(
() =>
new Promise<
Awaited<ReturnType<ImportSourcesController["connectForOnboarding"]>>
>((resolve) => {
resolveImport = resolve;
}),
),
});
const finish = vi.fn();
setMemoryMode.mockResolvedValueOnce({ mode: "lite" });
window.oneMemory = {} as NonNullable<typeof window.oneMemory>;
render(
<OnboardingFlow
state={onboarding}
imports={controller}
onFinish={finish}
/>,
);
await choose(user);
await approve(user);
await user.click(screen.getByRole("button", { name: START }));
await user.click(
await screen.findByRole("button", { name: "选择记忆模式" }),
);
await selectMode(user, /lite/i);
const enter = screen.getByRole("button", { name: "使用 Lite 进入" });
expect(enter).toHaveProperty("disabled", false);
await user.click(enter);
await user.click(enter);
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
expect(setMemoryMode).toHaveBeenCalledExactlyOnceWith("lite");
expect(completeOnboarding).toHaveBeenCalledOnce();
await act(async () =>
resolveImport({
accepted_source_ids: ["claude"],
failed_sources: {},
jobs: [],
}),
);
expect(finish).toHaveBeenCalledOnce();
expect(completeOnboarding).toHaveBeenCalledOnce();
});
it("shows distinct saving and completion feedback after one Lite click", async () => {
const user = userEvent.setup();
let resolveMode!: (value: { mode: "lite" }) => void;
let resolveCompletion!: (value: OnboardingState) => void;
setMemoryMode.mockReturnValueOnce(
new Promise((resolve) => {
resolveMode = resolve;
}),
);
completeOnboarding.mockReturnValueOnce(
new Promise((resolve) => {
resolveCompletion = resolve;
}),
);
const finish = vi.fn();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={finish}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await selectMode(user, /lite/i);
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
expect(
screen.getByRole("button", { name: "正在保存模式…" }),
).toHaveProperty("disabled", true);
await act(async () => resolveMode({ mode: "lite" }));
await waitFor(() =>
expect(
screen.getByRole("button", { name: "正在完成首次设置…" }),
).toHaveProperty("disabled", true),
);
expect(finish).not.toHaveBeenCalled();
await act(async () =>
resolveCompletion({
...onboarding,
status: "completed",
}),
);
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
});
it("fences duplicate Pro activation receipts while completion is pending", async () => {
const user = userEvent.setup();
let resolveCompletion!: (value: OnboardingState) => void;
completeOnboarding.mockReturnValueOnce(
new Promise((resolve) => {
resolveCompletion = resolve;
}),
);
const finish = vi.fn();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={finish}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
const receipt = screen.getByRole("button", {
name: "模型启用回执已确认",
});
fireEvent.click(receipt);
fireEvent.click(receipt);
await waitFor(() => expect(completeOnboarding).toHaveBeenCalledOnce());
expect(setMemoryMode).toHaveBeenCalledExactlyOnceWith("pro");
expect(finish).not.toHaveBeenCalled();
await act(async () =>
resolveCompletion({
...onboarding,
status: "completed",
}),
);
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
});
it("D47 entering the same-page model step does not finish setup or grant processing", async () => {
const finish = vi.fn();
render(
@@ -462,7 +647,7 @@ describe("OnboardingFlow S1.3", () => {
expect(screen.getByRole("region", { name: "模型步骤边界" })).toBeTruthy();
expect(completeOnboarding).not.toHaveBeenCalled();
expect(finish).not.toHaveBeenCalled();
expect(getModelProfiles).not.toHaveBeenCalled();
expect(getModelProfiles).toHaveBeenCalledOnce();
});
it("does not trust loaded metadata or select sources before automatic discovery resolves", async () => {
@@ -724,7 +909,7 @@ describe("OnboardingFlow S1.3", () => {
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
expect(onFinish).toHaveBeenCalledWith("sources");
expect(getModelProfiles).not.toHaveBeenCalled();
expect(getModelProfiles).toHaveBeenCalledOnce();
expect(getMemoryProcessing).not.toHaveBeenCalled();
expect(
screen.queryByRole("heading", { name: "配置一个模型,记忆才会生成" }),
@@ -744,7 +929,7 @@ describe("OnboardingFlow S1.3", () => {
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /lite/i }));
await selectMode(user, /lite/i);
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
expect(onFinish).toHaveBeenCalledWith("sources");
expect(completeOnboarding).toHaveBeenCalledOnce();
@@ -767,25 +952,330 @@ describe("OnboardingFlow S1.3", () => {
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
expect(screen.getByText("之后可在设置中更改。")).toBeTruthy();
expect(screen.getAllByRole("radio")).toHaveLength(2);
expect(screen.queryByRole("radio", { name: /ultra/i })).toBeNull();
expect(screen.getAllByRole("radio")).toHaveLength(3);
expect(screen.getByRole("radio", { name: /ultra/i })).toBeTruthy();
expect(screen.getByText("推荐").closest("label")?.textContent).toContain(
"Pro",
"pro",
);
expect(
screen.getByText("Ultra 可在完成首次设置后到设置页开启。"),
).toBeTruthy();
const modeStep = screen
.getByRole("navigation", { name: "首次设置进度" })
.querySelector('[aria-current="step"]');
expect(modeStep?.getAttribute("aria-current")).toBe("step");
await user.click(screen.getByRole("radio", { name: /lite/i }));
await selectMode(user, /lite/i);
expect(screen.getByRole("button", { name: "使用 Lite 进入" })).toBeTruthy();
await user.click(screen.getByRole("radio", { name: /pro/i }));
await selectMode(user, /pro/i);
expect(screen.getByRole("button", { name: "继续配置模型" })).toBeTruthy();
});
it("configures Ultra scheduling before model setup and completes after activation", async () => {
const user = userEvent.setup();
vi.mocked(api.getEvolveSchedule).mockResolvedValueOnce({
enabled: true,
time: "03:00",
});
vi.mocked(api.setEvolveSchedule).mockResolvedValueOnce({
enabled: true,
time: "03:00",
});
planMemoryMode.mockImplementation(async (target) => ({
current: "pro",
target,
effective_target: target,
capabilities: {
content_plane: "memory",
generation_required: target !== "lite",
graph_enabled: target === "ultra",
evolve_available: target === "ultra",
},
effects: target === "ultra" ? ["existing_memories_added_to_graph"] : [],
warnings: [],
blockers:
target !== "lite" && !modelActive ? ["active_model_required"] : [],
}));
setMemoryMode.mockResolvedValueOnce({ mode: "ultra" });
const finish = vi.fn();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={finish}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /ultra/i }));
expect(
await screen.findByRole("heading", {
name: "选择深度记忆(ultra)?",
}),
).toBeTruthy();
expect(
screen.getByText("使用 pro 或 ultra 前需要先配置并启用生成模型。"),
).toBeTruthy();
await user.click(screen.getByRole("button", { name: "确认选择" }));
expect(
screen
.getByRole("switch", { name: "每天自动整合" })
.getAttribute("aria-checked"),
).toBe("true");
expect(
screen.getByRole("button", { name: "执行时间,03:00" }),
).toBeTruthy();
await user.click(screen.getByRole("button", { name: "继续" }));
expect(screen.getByRole("region", { name: "模型步骤边界" })).toBeTruthy();
expect(putOnboardingProgress).toHaveBeenCalledWith(
expect.objectContaining({
mode_intent: "ultra",
setup_step: "model",
}),
);
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("ultra"));
expect(api.setEvolveSchedule).toHaveBeenCalledWith({
enabled: true,
time: "03:00",
});
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
});
it("does not let a resumed Ultra model overwrite an unread schedule", async () => {
let resolveSchedule!: (value: { enabled: boolean; time: string }) => void;
vi.mocked(api.getEvolveSchedule).mockReturnValueOnce(
new Promise((resolve) => {
resolveSchedule = resolve;
}),
);
setMemoryMode.mockResolvedValueOnce({ mode: "ultra" });
vi.mocked(api.setEvolveSchedule).mockResolvedValueOnce({
enabled: false,
time: "05:30",
});
const user = userEvent.setup();
const finish = vi.fn();
render(
<OnboardingFlow
state={{
...onboarding,
mode_intent: "ultra",
setup_step: "model",
model_profile_id: "9be6d465-4cf2-4c72-93f7-15f3c012bc80",
}}
imports={imports({ sources: [] })}
onFinish={finish}
/>,
);
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
expect(setMemoryMode).not.toHaveBeenCalled();
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
expect(screen.getByRole("alert").textContent).toContain("正在读取记忆模式");
await act(async () =>
resolveSchedule({
enabled: false,
time: "05:30",
}),
);
await waitFor(() =>
expect(screen.getByRole("button", { name: "继续" })).toHaveProperty(
"disabled",
false,
),
);
expect(
screen
.getByRole("switch", { name: "每天自动整合" })
.getAttribute("aria-checked"),
).toBe("false");
await user.click(screen.getByRole("button", { name: "继续" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
expect(api.setEvolveSchedule).toHaveBeenCalledWith({
enabled: false,
time: "05:30",
});
});
it("keeps Ultra selected when schedule persistence fails and retries", async () => {
modelActive = true;
getModelProfiles.mockResolvedValue([{ is_active: true }]);
vi.mocked(api.getEvolveSchedule).mockResolvedValueOnce({
enabled: true,
time: "03:00",
});
vi.mocked(api.setEvolveSchedule)
.mockRejectedValueOnce(new Error("offline"))
.mockResolvedValueOnce({ enabled: true, time: "03:00" });
setMemoryMode.mockResolvedValue({ mode: "ultra" });
const user = userEvent.setup();
const finish = vi.fn();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={finish}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await selectMode(user, /ultra/i);
await user.click(screen.getByRole("button", { name: "继续" }));
expect((await screen.findByRole("alert")).textContent).toContain(
"自动整合设置未能保存",
);
expect(finish).not.toHaveBeenCalled();
expect(
screen.getByRole<HTMLInputElement>("radio", { name: /ultra/i }).checked,
).toBe(true);
await user.click(screen.getByRole("button", { name: "继续" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
expect(api.setEvolveSchedule).toHaveBeenCalledTimes(2);
});
it("retries a failed Ultra schedule read without losing the selection", async () => {
vi.mocked(api.getEvolveSchedule)
.mockRejectedValueOnce(new Error("offline"))
.mockResolvedValueOnce({ enabled: true, time: "04:15" });
const user = userEvent.setup();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={vi.fn()}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await selectMode(user, /ultra/i);
expect(screen.getByRole("alert").textContent).toContain(
"记忆模式状态暂时无法读取",
);
expect(screen.getByRole("button", { name: "继续" })).toHaveProperty(
"disabled",
true,
);
await user.click(screen.getByRole("button", { name: "重试" }));
await waitFor(() =>
expect(
screen.getByRole("button", { name: "执行时间,04:15" }),
).toBeTruthy(),
);
expect(
screen.getByRole<HTMLInputElement>("radio", { name: /ultra/i }).checked,
).toBe(true);
expect(screen.getByRole("button", { name: "继续" })).toHaveProperty(
"disabled",
false,
);
});
it("drops a late mode plan after leaving the mode step", async () => {
let resolvePlan!: (value: {
current: "pro";
target: "lite";
effective_target: "lite";
capabilities: {
content_plane: "raw";
generation_required: false;
graph_enabled: false;
evolve_available: false;
};
effects: [];
warnings: [];
blockers: [];
}) => void;
planMemoryMode.mockReturnValueOnce(
new Promise((resolve) => {
resolvePlan = resolve;
}),
);
const user = userEvent.setup();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={vi.fn()}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /lite/i }));
await user.click(screen.getByRole("button", { name: "返回导入" }));
await act(async () =>
resolvePlan({
current: "pro",
target: "lite",
effective_target: "lite",
capabilities: {
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
},
effects: [],
warnings: [],
blockers: [],
}),
);
expect(screen.queryByRole("dialog")).toBeNull();
expect(
screen.getByRole("heading", {
name: "对话正在汇入,记忆从这里开始",
}),
).toBeTruthy();
});
it("persists Lite mode before resumable progress and completion", async () => {
const order: string[] = [];
setMemoryMode.mockImplementationOnce(async () => {
order.push("mode");
return { mode: "lite" };
});
putOnboardingProgress.mockImplementationOnce(async (update) => {
order.push("progress");
return { ...onboarding, ...update };
});
completeOnboarding.mockImplementationOnce(async () => {
order.push("complete");
return { ...onboarding, status: "completed" };
});
const user = userEvent.setup();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={vi.fn()}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await chooseLiteAndContinue(user);
await waitFor(() =>
expect(order).toEqual(["mode", "progress", "complete"]),
);
});
it("does not let a late saved-mode read overwrite the user's new choice", async () => {
let resolveMode!: (value: {
mode: "pro";
@@ -809,7 +1299,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
const lite = screen.getByRole<HTMLInputElement>("radio", { name: /lite/i });
await user.click(lite);
await selectMode(user, /lite/i);
await act(async () => {
resolveMode({
mode: "pro",
@@ -843,7 +1333,7 @@ describe("OnboardingFlow S1.3", () => {
"disabled",
true,
);
await user.click(lite);
await selectMode(user, /lite/i);
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
expect((await screen.findByRole("alert")).textContent).toContain(
@@ -862,6 +1352,7 @@ describe("OnboardingFlow S1.3", () => {
it("serializes an in-flight Pro PUT before a later Lite choice so Lite wins", async () => {
let resolvePro!: (value: { mode: "pro" }) => void;
let resolveLite!: (value: { mode: "lite" }) => void;
setMemoryMode
.mockImplementationOnce(
() =>
@@ -869,7 +1360,12 @@ describe("OnboardingFlow S1.3", () => {
resolvePro = resolve;
}),
)
.mockResolvedValueOnce({ mode: "lite" });
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveLite = resolve;
}),
);
const user = userEvent.setup();
const finish = vi.fn();
render(
@@ -882,7 +1378,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
@@ -890,7 +1386,7 @@ describe("OnboardingFlow S1.3", () => {
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("pro"));
await user.click(screen.getByRole("button", { name: "返回导入" }));
await user.click(screen.getByRole("radio", { name: /lite/i }));
await selectMode(user, /lite/i);
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
expect(setMemoryMode).toHaveBeenCalledTimes(1);
@@ -900,10 +1396,119 @@ describe("OnboardingFlow S1.3", () => {
"pro",
"lite",
]);
expect(
screen.getByRole("button", { name: "正在保存模式…" }),
).toHaveProperty("disabled", true);
await act(async () => resolveLite({ mode: "lite" }));
await waitFor(() => expect(finish).toHaveBeenCalledWith("sources"));
expect(completeOnboarding).toHaveBeenCalledOnce();
});
it("accepts a new Pro generation while an older Pro write is pending", async () => {
let resolveFirst!: (value: { mode: "pro" }) => void;
let resolveSecond!: (value: { mode: "pro" }) => void;
setMemoryMode
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveFirst = resolve;
}),
)
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveSecond = resolve;
}),
);
const user = userEvent.setup();
const finish = vi.fn();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={finish}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledTimes(1));
await user.click(screen.getByRole("button", { name: "返回导入" }));
modelActive = false;
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
expect(setMemoryMode).toHaveBeenCalledTimes(1);
await act(async () => resolveFirst({ mode: "pro" }));
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledTimes(2));
expect(finish).not.toHaveBeenCalled();
await act(async () => resolveSecond({ mode: "pro" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
expect(completeOnboarding).toHaveBeenCalledOnce();
});
it("accepts a new profile receipt within the same Pro generation", async () => {
let resolveFirst!: (value: { mode: "pro" }) => void;
let resolveSecond!: (value: { mode: "pro" }) => void;
setMemoryMode
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveFirst = resolve;
}),
)
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveSecond = resolve;
}),
);
const user = userEvent.setup();
const finish = vi.fn();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={finish}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledTimes(1));
await user.click(
screen.getByRole("button", { name: "第二个模型启用回执已确认" }),
);
expect(setMemoryMode).toHaveBeenCalledTimes(1);
await act(async () => resolveFirst({ mode: "pro" }));
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledTimes(2));
expect(finish).not.toHaveBeenCalled();
await act(async () => resolveSecond({ mode: "pro" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
expect(completeOnboarding).toHaveBeenCalledOnce();
});
it("drops an in-flight Pro response after returning to mode without confirming or completing", async () => {
let resolvePro!: (value: { mode: "pro" }) => void;
setMemoryMode.mockImplementationOnce(
@@ -923,7 +1528,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
const revisionBeforePut = confirmedMemoryModeSnapshot().revision;
await user.click(
@@ -972,7 +1577,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
@@ -1012,7 +1617,7 @@ describe("OnboardingFlow S1.3", () => {
const firstTitle = screen.getByRole("heading", { name: "选择记忆模式" });
await waitFor(() => expect(document.activeElement).toBe(firstTitle));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(screen.getByRole("button", { name: "返回导入" }));
const returnedTitle = screen.getByRole("heading", {
@@ -1042,7 +1647,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await waitFor(() => expect(finish).toHaveBeenCalledWith("sources"));
expect(screen.queryByRole("alert")).toBeNull();
@@ -1061,7 +1666,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
const lateReceipt = screen.getByRole("button", {
name: "模型启用回执已确认",
@@ -1110,7 +1715,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
const pro = screen.getByRole<HTMLInputElement>("radio", { name: /pro/i });
await user.click(pro);
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
expect((await screen.findByRole("alert")).textContent).toContain(
"模式未能保存",
@@ -1143,11 +1748,11 @@ describe("OnboardingFlow S1.3", () => {
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
expect(completeOnboarding).not.toHaveBeenCalled();
expect(onFinish).not.toHaveBeenCalled();
await user.click(screen.getByRole("radio", { name: /lite/i }));
await selectMode(user, /lite/i);
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
expect(completeOnboarding).toHaveBeenCalledOnce();
expect(onFinish).toHaveBeenCalledWith("sources");
expect(getModelProfiles).not.toHaveBeenCalled();
expect(getModelProfiles).toHaveBeenCalledOnce();
expect(getMemoryProcessing).not.toHaveBeenCalled();
});
+467 -180
View File
@@ -1,9 +1,14 @@
import type {
ConnectSourcesResponse,
EvolveSchedule,
ImportJob,
MemoryModePlan,
MemoryPipelineMode,
OnboardingModeIntent,
OnboardingState,
SourceResponse,
} from "@one-memory/contracts";
import { legacyMemoryModePlan } from "@one-memory/operations";
import { Check, X } from "lucide-react";
import {
type CSSProperties,
@@ -18,10 +23,14 @@ import {
import {
apiErrorCode,
completeOnboarding,
getEvolveSchedule,
getMemoryMode,
getModelProfiles,
memoryModePlanUnsupported,
patchOnboardingProgress,
planMemoryMode,
putOnboardingProgress,
setEvolveSchedule,
setMemoryMode,
} from "./api";
import { AppMark } from "./app-mark";
@@ -41,17 +50,23 @@ import {
hostWriteReason,
} from "./host-state";
import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
import {
BLOCKER_COPY,
MemoryModeConfirmDialog,
MODE_COPY,
} from "./memory-mode-ui";
import { OnboardingModelController } from "./OnboardingModelController";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6ColdStartFrame } from "./R6ColdStartFrame";
import { R6Button } from "./R6Controls";
import { UltraEvolveScheduleFields } from "./UltraEvolveScheduleFields";
import { traceUiAction } from "./ui-trace";
import type { ImportSourcesController } from "./use-import-sources";
import { isDefiniteRefusal } from "./write-outcome";
import "./cold-start.css";
export type OnboardingDestination = "memory" | "sources" | "settings";
type OnboardingMemoryMode = "lite" | "pro";
type OnboardingMemoryMode = OnboardingModeIntent;
// Cold start neither reads nor writes installation intent.
export const PLUGIN_CONSENT_KEY = "one-memory.consent.plugins";
@@ -112,9 +127,8 @@ export function OnboardingFlow({
const [locationCheck, setLocationCheck] = useState<LocationCheck>("checking");
const [selected, setSelected] = useState<SourceResponse[]>([]);
const [custom, setCustom] = useState<SourceResponse | null>(null);
const [busy, setBusy] = useState<
"checking" | "submitting" | "leaving" | null
>(null);
const [busy, setBusy] = useState<"checking" | "submitting" | null>(null);
const [completionBusy, setCompletionBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [itemErrors, setItemErrors] = useState<Record<string, string>>({});
const [exitDestination, setExitDestination] =
@@ -125,7 +139,9 @@ export function OnboardingFlow({
null,
);
const resumedStep =
state.mode_intent === "pro" && state.setup_step === "model"
state.mode_intent &&
state.mode_intent !== "lite" &&
state.setup_step === "model"
? "model"
: state.mode_intent
? "mode"
@@ -138,8 +154,26 @@ export function OnboardingFlow({
);
const [modeDestination, setModeDestination] =
useState<OnboardingDestination>("sources");
const [modeSaving, setModeSaving] = useState(false);
const [modePhase, setModePhase] = useState<"idle" | "saving" | "completing">(
"idle",
);
const modeSaving = modePhase !== "idle";
const [modeError, setModeError] = useState<string | null>(null);
const [knownMode, setKnownMode] = useState<MemoryPipelineMode>("pro");
const [pendingMode, setPendingMode] = useState<OnboardingMemoryMode | null>(
null,
);
const [pendingPlan, setPendingPlan] = useState<MemoryModePlan | null>(null);
const [modePlanBusy, setModePlanBusy] = useState(false);
const modeDialogOpener = useRef<HTMLElement | null>(null);
const modePlanOperation = useRef(0);
const [ultraSchedule, setUltraSchedule] = useState<EvolveSchedule>({
enabled: true,
time: "03:00",
});
const [scheduleLoaded, setScheduleLoaded] = useState(false);
const [scheduleReadFailed, setScheduleReadFailed] = useState(false);
const scheduleReadOperation = useRef(0);
const [modeFocusToken, setModeFocusToken] = useState(
resumedStep === "mode" ? 1 : 0,
);
@@ -181,7 +215,10 @@ export function OnboardingFlow({
)
.join("、"),
});
const flight = useRef(false);
const sourceFlight = useRef(false);
const modeOperation = useRef(0);
const proConfirmationOwner = useRef<string | null>(null);
const completionFlight = useRef(false);
const connectionAttempt = useRef<string | null>(null);
const connectionQueue = useRef(Promise.resolve());
const connectionQueueDepth = useRef(0);
@@ -368,20 +405,24 @@ export function OnboardingFlow({
selected.some((chosen) => chosen.source_id === item.source_id),
);
const locked =
busy !== null || exitDestination !== null || state.status === "importing";
busy !== null ||
completionBusy ||
exitDestination !== null ||
state.status === "importing";
useEffect(() => {
alive.current = true;
const modeTicket = issueConfirmedModeTicket("onboarding");
void getMemoryMode().then(
(memoryMode) => {
if (alive.current)
if (alive.current) {
commitConfirmedMemoryMode(memoryMode.mode, modeTicket);
setKnownMode(memoryMode.mode);
}
if (
alive.current &&
!modeChoiceTouched.current &&
memoryMode.explicitly_configured &&
memoryMode.configured_mode !== "ultra"
memoryMode.explicitly_configured
)
setModeChoice(memoryMode.configured_mode);
},
@@ -394,6 +435,32 @@ export function OnboardingFlow({
};
}, []);
const refreshUltraSchedule = useCallback(async () => {
const operation = ++scheduleReadOperation.current;
setScheduleLoaded(false);
setScheduleReadFailed(false);
try {
const schedule = await getEvolveSchedule();
if (!alive.current || scheduleReadOperation.current !== operation) return;
if (!schedule) return;
setUltraSchedule(schedule);
setScheduleLoaded(true);
setScheduleReadFailed(false);
setModeError((current) =>
current === "记忆模式状态暂时无法读取,请重试。" ? null : current,
);
} catch {
if (alive.current && scheduleReadOperation.current === operation) {
setScheduleLoaded(false);
setScheduleReadFailed(true);
}
}
}, []);
useEffect(() => {
void refreshUltraSchedule();
}, [refreshUltraSchedule]);
useEffect(() => {
setSources(imports.sources);
}, [imports.sources]);
@@ -456,7 +523,7 @@ export function OnboardingFlow({
locationCheck !== "ready" ||
locked ||
reviewingImport ||
flight.current
sourceFlight.current
)
return;
if (!scopeMatches(selected, sources)) return;
@@ -483,17 +550,24 @@ export function OnboardingFlow({
[onFinish],
);
const enterModelStep = useCallback(() => {
intentRef.current = "pro";
setupStepRef.current = "model";
const token = ++modelGeneration.current;
setModelGenerationToken(token);
setVisitedModel(true);
setSetupStep("model");
}, []);
const enterModelStep = useCallback(
(mode: Exclude<OnboardingMemoryMode, "lite"> = "pro") => {
intentRef.current = mode;
setupStepRef.current = "model";
const token = ++modelGeneration.current;
setModelGenerationToken(token);
setVisitedModel(true);
setSetupStep("model");
},
[],
);
const leaveModelStep = useCallback(
(nextIntent: OnboardingMemoryMode, nextStep: "review" | "mode") => {
(
nextIntent: OnboardingMemoryMode,
nextStep: "review" | "mode",
resetModePhase = true,
) => {
// This fence is synchronous: hidden model work must observe invalidation
// before React renders the replacement step.
modelGeneration.current += 1;
@@ -502,7 +576,10 @@ export function OnboardingFlow({
setModeChoice(nextIntent);
setSetupStep(nextStep);
if (nextStep === "mode") setModeFocusToken((value) => value + 1);
setModeSaving(false);
if (resetModePhase) {
modeOperation.current += 1;
setModePhase("idle");
}
},
[],
);
@@ -611,12 +688,15 @@ export function OnboardingFlow({
const leave = useCallback(
async (destination: OnboardingDestination) => {
if (flight.current || finished.current) return;
flight.current = true;
setBusy("leaving");
await persistFinish(destination);
flight.current = false;
if (alive.current) setBusy(null);
if (completionFlight.current || finished.current) return;
completionFlight.current = true;
setCompletionBusy(true);
try {
await persistFinish(destination);
} finally {
completionFlight.current = false;
if (alive.current) setCompletionBusy(false);
}
},
[persistFinish],
);
@@ -624,7 +704,12 @@ export function OnboardingFlow({
const requestModeChoice = useCallback(
(destination: OnboardingDestination) => {
if (finished.current) return;
if (setupStepRef.current === "model") leaveModelStep("pro", "mode");
modePlanOperation.current += 1;
setModePlanBusy(false);
setPendingMode(null);
setPendingPlan(null);
if (setupStepRef.current === "model")
leaveModelStep(intentRef.current === "ultra" ? "ultra" : "pro", "mode");
else {
intentRef.current = modeChoice;
setupStepRef.current = "mode";
@@ -639,30 +724,123 @@ export function OnboardingFlow({
[leaveModelStep, modeChoice],
);
const requestModeSelection = useCallback(
async (mode: OnboardingMemoryMode, opener: HTMLElement) => {
if (modePlanBusy || modeSaving || completionBusy) return;
const operation = ++modePlanOperation.current;
setModePlanBusy(true);
setModeError(null);
try {
let plan: MemoryModePlan;
try {
plan = await planMemoryMode(mode);
} catch (reason) {
if (!memoryModePlanUnsupported(reason)) throw reason;
const profiles = await getModelProfiles();
plan = legacyMemoryModePlan(knownMode, mode, {
hasActiveModel: profiles.some((profile) => profile.is_active),
});
}
if (
!alive.current ||
modePlanOperation.current !== operation ||
setupStepRef.current !== "mode"
)
return;
modeDialogOpener.current = opener;
setPendingMode(mode);
setPendingPlan(plan);
} catch {
if (
alive.current &&
modePlanOperation.current === operation &&
setupStepRef.current === "mode"
)
setModeError("模式切换影响暂时无法读取,请重试。");
} finally {
if (alive.current && modePlanOperation.current === operation)
setModePlanBusy(false);
}
},
[completionBusy, knownMode, modePlanBusy, modeSaving],
);
const saveModeChoice = useCallback(async () => {
if (
!modeChoice ||
modeSaving ||
finished.current ||
(modeChoice === "lite" && flight.current)
)
return;
setModeSaving(true);
if (!modeChoice || modeSaving || finished.current || completionBusy) return;
const operation = ++modeOperation.current;
const ownsModeOperation = () =>
alive.current && !finished.current && modeOperation.current === operation;
setModePhase("saving");
setModeError(null);
const modeTicket = issueConfirmedModeTicket("onboarding");
try {
if (modeChoice === "pro") {
intentRef.current = "pro";
if (modeChoice !== "lite") {
const target = modeChoice;
const [plan, profiles] = await Promise.all([
planMemoryMode(target),
getModelProfiles(),
]);
if (!ownsModeOperation()) return;
const hasActiveModel = profiles.some((profile) => profile.is_active);
if (target === "ultra" && !scheduleLoaded) {
setModeError(
scheduleReadFailed
? "记忆模式状态暂时无法读取,请重试。"
: "正在读取记忆模式…",
);
return;
}
const blocking = plan.blockers.filter(
(blocker) => blocker !== "active_model_required",
);
if (blocking.length > 0) {
setModeError(
blocking.map((blocker) => BLOCKER_COPY[blocker]).join(" "),
);
return;
}
intentRef.current = target;
setupStepRef.current = "mode";
const selectionStillCurrent = () =>
alive.current &&
!finished.current &&
intentRef.current === "pro" &&
ownsModeOperation() &&
intentRef.current === target &&
setupStepRef.current === "mode";
if (
hasActiveModel &&
!plan.blockers.includes("active_model_required")
) {
const changed = await queueModeWrite(target, selectionStillCurrent);
if (!selectionStillCurrent()) return;
commitConfirmedMemoryMode(changed.mode, modeTicket);
setKnownMode(changed.mode);
if (target === "ultra") {
try {
await setEvolveSchedule(ultraSchedule);
} catch {
if (selectionStillCurrent())
setModeError("自动整合设置未能保存,请重试。");
return;
}
if (!selectionStillCurrent()) return;
}
await queueProgressWrite(
() =>
putOnboardingProgress({
mode_intent: target,
setup_step: "mode",
model_profile_id: null,
}),
selectionStillCurrent,
);
if (!selectionStillCurrent()) return;
setModePhase("completing");
await leave(modeDestination);
return;
}
const savedProgress = await queueProgressWrite(
() =>
putOnboardingProgress({
mode_intent: "pro",
mode_intent: target,
setup_step: "model",
model_profile_id: modelProfileId.current,
}),
@@ -670,15 +848,18 @@ export function OnboardingFlow({
);
if (!selectionStillCurrent()) return;
modelProfileId.current = savedProgress.model_profile_id;
enterModelStep();
enterModelStep(target);
return;
}
leaveModelStep("lite", "mode");
leaveModelStep("lite", "mode", false);
const liteStillCurrent = () =>
alive.current &&
!finished.current &&
ownsModeOperation() &&
intentRef.current === "lite" &&
setupStepRef.current === "mode";
const changed = await queueModeWrite("lite", liteStillCurrent);
if (!liteStillCurrent()) return;
commitConfirmedMemoryMode(changed.mode, modeTicket);
setKnownMode(changed.mode);
await queueProgressWrite(
() =>
putOnboardingProgress({
@@ -688,19 +869,17 @@ export function OnboardingFlow({
}),
liteStillCurrent,
);
if (!alive.current) return;
const changed = await queueModeWrite("lite");
if (!alive.current) return;
commitConfirmedMemoryMode(changed.mode, modeTicket);
if (!liteStillCurrent()) return;
setModePhase("completing");
await leave(modeDestination);
} catch (reason) {
if (isCompletedProgressConflict(reason)) {
finishOnce(modeDestination);
} else if (alive.current) {
} else if (ownsModeOperation() && !isGenerationInvalid(reason)) {
setModeError("模式未能保存。你的选择已保留,请重试。");
}
} finally {
if (alive.current) setModeSaving(false);
if (ownsModeOperation()) setModePhase("idle");
}
}, [
enterModelStep,
@@ -709,9 +888,13 @@ export function OnboardingFlow({
modeChoice,
modeDestination,
modeSaving,
completionBusy,
finishOnce,
queueModeWrite,
queueProgressWrite,
scheduleLoaded,
scheduleReadFailed,
ultraSchedule,
]);
const persistModelProfile = useCallback(
@@ -729,58 +912,100 @@ export function OnboardingFlow({
[queueProgressWrite],
);
const confirmProMode = useCallback(
const confirmModelMode = useCallback(
async (token: number, ownerProfileId: string) => {
const target = intentRef.current;
if (target !== "pro" && target !== "ultra") return;
if (target === "ultra" && !scheduleLoaded) {
leaveModelStep("ultra", "mode");
setModeError(
scheduleReadFailed
? "记忆模式状态暂时无法读取,请重试。"
: "正在读取记忆模式…",
);
return;
}
const confirmationOwner = `${token}:${ownerProfileId}:${target}`;
const ownsModelStep = () =>
alive.current &&
!finished.current &&
modelGeneration.current === token &&
intentRef.current === "pro" &&
intentRef.current === target &&
setupStepRef.current === "model" &&
modelProfileId.current === ownerProfileId;
if (!ownsModelStep()) return;
setModeSaving(true);
if (
!ownsModelStep() ||
proConfirmationOwner.current === confirmationOwner ||
completionBusy
)
return;
proConfirmationOwner.current = confirmationOwner;
const operation = ++modeOperation.current;
const ownsConfirmation = () =>
ownsModelStep() && modeOperation.current === operation;
setModePhase("saving");
setModeError(null);
const modeTicket = issueConfirmedModeTicket("onboarding");
try {
const plan = await planMemoryMode("pro");
if (!ownsModelStep()) return;
const plan = await planMemoryMode(target);
if (!ownsConfirmation()) return;
if (plan.blockers.length > 0) throw new Error(plan.blockers.join(","));
const changed = await queueModeWrite("pro", ownsModelStep);
if (!ownsModelStep()) return;
const changed = await queueModeWrite(target, ownsConfirmation);
if (!ownsConfirmation()) return;
commitConfirmedMemoryMode(changed.mode, modeTicket);
setKnownMode(changed.mode);
if (target === "ultra") {
try {
await setEvolveSchedule(ultraSchedule);
} catch {
if (ownsConfirmation()) {
leaveModelStep("ultra", "mode");
setModeError("自动整合设置未能保存,请重试。");
}
return;
}
if (!ownsConfirmation()) return;
}
await queueProgressWrite(
() =>
putOnboardingProgress({
mode_intent: "pro",
mode_intent: target,
setup_step: "model",
model_profile_id: ownerProfileId,
}),
ownsModelStep,
ownsConfirmation,
);
if (!ownsModelStep()) return;
await persistFinish(modeDestination);
if (!ownsConfirmation()) return;
setModePhase("completing");
await leave(modeDestination);
} catch (reason) {
if (isCompletedProgressConflict(reason)) {
finishOnce(modeDestination);
} else if (alive.current && !isGenerationInvalid(reason))
setModeError("Pro 模式未能确认保存。模型配置已保留,请重试。");
} else if (ownsConfirmation() && !isGenerationInvalid(reason))
setModeError("模式切换未能确认,仍显示上次确认的模式。");
} finally {
if (alive.current) setModeSaving(false);
if (proConfirmationOwner.current === confirmationOwner)
proConfirmationOwner.current = null;
if (ownsConfirmation()) setModePhase("idle");
}
},
[
completionBusy,
finishOnce,
leave,
leaveModelStep,
modeDestination,
persistFinish,
queueModeWrite,
queueProgressWrite,
scheduleLoaded,
scheduleReadFailed,
ultraSchedule,
],
);
async function checkLocations() {
if (flight.current || locked) return;
flight.current = true;
if (sourceFlight.current || locked) return;
sourceFlight.current = true;
scopeVersion.current += 1;
setBusy("checking");
setLocationCheck("checking");
@@ -805,7 +1030,7 @@ export function OnboardingFlow({
setLocationCheck("failed");
setError("位置检测未完成,尚未读取历史。可以重新检测,或先进入工作台。");
} finally {
flight.current = false;
sourceFlight.current = false;
if (alive.current) setBusy(null);
}
}
@@ -813,7 +1038,7 @@ export function OnboardingFlow({
function toggleSource(source: SourceResponse, checked: boolean) {
if (
flight.current ||
sourceFlight.current ||
locked ||
locationCheck !== "ready" ||
!isAppSource(source) ||
@@ -836,7 +1061,7 @@ export function OnboardingFlow({
}
function selectAllSources() {
if (flight.current || locked || locationCheck !== "ready") return;
if (sourceFlight.current || locked || locationCheck !== "ready") return;
scopeVersion.current += 1;
setError(null);
setItemErrors({});
@@ -849,7 +1074,7 @@ export function OnboardingFlow({
async function startImport() {
if (
flight.current ||
sourceFlight.current ||
locked ||
locationCheck !== "ready" ||
!selected.length
@@ -863,7 +1088,7 @@ export function OnboardingFlow({
}
async function submitImport(approved: SourceResponse[]) {
flight.current = true;
sourceFlight.current = true;
if (window.oneMemory) setStartingImport(approved);
const version = scopeVersion.current;
const retryFailed = Object.keys(itemErrors).length > 0;
@@ -917,7 +1142,7 @@ export function OnboardingFlow({
}
} finally {
connectSent.current = false;
flight.current = false;
sourceFlight.current = false;
if (alive.current) setBusy(null);
}
}
@@ -987,10 +1212,10 @@ export function OnboardingFlow({
jobs={reviewJobs}
failedSources={accepted?.failed_sources}
sourceSnapshot={imports.sourceSnapshot}
busy={busy === "leaving"}
busy={completionBusy}
error={error && text(error)}
onContinue={(destination) => {
if (busy === "leaving") return;
if (completionBusy) return;
if (completionFailed) {
void leave(destination);
return;
@@ -1022,106 +1247,166 @@ export function OnboardingFlow({
/>
</div>
{setupStep === "mode" && (
<R6ColdStartFrame
step={3}
title={text("选择记忆模式")}
eyebrow={text("最后一步 · 决定如何处理对话")}
focusToken={modeFocusToken}
footer={
<>
<R6Button
variant="quiet"
disabled={modeSaving || busy === "leaving"}
onClick={() => {
setModeError(null);
intentRef.current = modeChoice;
setupStepRef.current = "review";
setSetupStep("review");
void queueProgressWrite(() =>
patchOnboardingProgress({
mode_intent: modeChoice,
setup_step: "review",
}),
).catch((reason) => {
if (!isCompletedProgressConflict(reason) && alive.current)
setModeError(
"恢复进度未能保存。你的选择已保留,请重试。",
);
});
}}
>
{text("返回导入")}
</R6Button>
<R6Button
className="onboarding-r6-submit"
variant="primary"
pending={modeSaving}
disabled={
!modeChoice ||
busy === "leaving" ||
(modeChoice === "lite" && flight.current)
}
onClick={() => void saveModeChoice()}
>
{text(
modeSaving
? "正在保存…"
: modeChoice === "lite"
? "使用 Lite 进入"
: modeChoice === "pro"
? "继续配置模型"
: "继续",
)}
</R6Button>
</>
}
>
<section
className="onboarding-r6-mode"
aria-labelledby="onboarding-memory-mode-title"
<>
<R6ColdStartFrame
step={3}
title={text("选择记忆模式")}
eyebrow={text("最后一步 · 决定如何处理对话")}
focusToken={modeFocusToken}
footer={
<>
<R6Button
variant="quiet"
disabled={modeSaving || completionBusy}
onClick={() => {
modePlanOperation.current += 1;
setModePlanBusy(false);
setPendingMode(null);
setPendingPlan(null);
setModeError(null);
intentRef.current = modeChoice;
setupStepRef.current = "review";
setSetupStep("review");
void queueProgressWrite(() =>
patchOnboardingProgress({
mode_intent: modeChoice,
setup_step: "review",
}),
).catch((reason) => {
if (
!isCompletedProgressConflict(reason) &&
alive.current
)
setModeError(
"恢复进度未能保存。你的选择已保留,请重试。",
);
});
}}
>
{text("返回导入")}
</R6Button>
<R6Button
className="onboarding-r6-submit"
variant="primary"
pending={modeSaving || modePlanBusy}
disabled={
!modeChoice ||
completionBusy ||
modePlanBusy ||
(modeChoice === "ultra" && !scheduleLoaded)
}
onClick={() => void saveModeChoice()}
>
{text(
modePhase === "saving"
? "正在保存模式…"
: modePhase === "completing"
? "正在完成首次设置…"
: modeChoice === "lite"
? "使用 Lite 进入"
: modeChoice === "pro"
? "继续配置模型"
: modeChoice === "ultra"
? "继续"
: "继续",
)}
</R6Button>
</>
}
>
<div className="onboarding-r6-mode-intro">
<p id="onboarding-memory-mode-title">
{text("之后可在设置中更改。")}
</p>
</div>
<MemoryModeChoiceList
aria-label={text("记忆模式")}
modes={[
{
mode: "lite",
title: "Lite",
description: text(
"无需模型,只保存并检索原始对话,不生成提炼记忆。",
),
},
{
mode: "pro",
title: "Pro",
description: text(
"使用模型理解对话,提取并整理可持续生长的记忆。",
),
},
]}
selected={modeChoice}
disabled={modeSaving || busy === "leaving"}
recommended={{ mode: "pro", badge: text("推荐") }}
onChange={(mode) => {
<section
className="onboarding-r6-mode"
aria-labelledby="onboarding-memory-mode-title"
>
<div className="onboarding-r6-mode-intro">
<p id="onboarding-memory-mode-title">
{text("之后可在设置中更改。")}
</p>
</div>
<MemoryModeChoiceList
aria-label={text("记忆模式")}
modes={(["lite", "pro", "ultra"] as const).map((mode) => ({
mode,
title: text(MODE_COPY[mode].title),
description: text(MODE_COPY[mode].description),
}))}
selected={modeChoice}
disabled={modeSaving || completionBusy || modePlanBusy}
recommended={{ mode: "pro", badge: text("推荐") }}
onChange={(mode, control) =>
void requestModeSelection(
mode as OnboardingMemoryMode,
control,
)
}
/>
{modeChoice === "ultra" && scheduleLoaded ? (
<section
className="r6-evolve-settings onboarding-r6-evolve-settings"
aria-label={text("记忆整合(Evolve)")}
>
<UltraEvolveScheduleFields
schedule={ultraSchedule}
disabled={modeSaving || !scheduleLoaded}
onEnabledChange={(enabled) =>
setUltraSchedule((current) => ({
...current,
enabled,
}))
}
onTimeChange={async (time) => {
setUltraSchedule((current) => ({ ...current, time }));
return true;
}}
/>
</section>
) : null}
{modeChoice === "ultra" && scheduleReadFailed ? (
<div className="onboarding-r6-schedule-recovery">
<p role="alert">
{text("记忆模式状态暂时无法读取,请重试。")}
</p>
<R6Button
className="onboarding-r6-schedule-retry"
onClick={() => void refreshUltraSchedule()}
>
{text("重试")}
</R6Button>
</div>
) : null}
{(modeError || error) && (
<p className="onboarding-r6-mode-error" role="alert">
{text(modeError ?? error ?? "")}
</p>
)}
</section>
</R6ColdStartFrame>
{pendingMode && pendingPlan ? (
<MemoryModeConfirmDialog
mode={pendingMode}
plan={pendingPlan}
busy={modePlanBusy}
opener={modeDialogOpener.current}
allowedBlockers={["active_model_required"]}
onCancel={() => {
modePlanOperation.current += 1;
setModePlanBusy(false);
setPendingMode(null);
setPendingPlan(null);
}}
onConfirm={() => {
modePlanOperation.current += 1;
setModePlanBusy(false);
modeChoiceTouched.current = true;
setModeChoice(mode as OnboardingMemoryMode);
intentRef.current = pendingMode;
setModeChoice(pendingMode);
setModeError(null);
setPendingMode(null);
setPendingPlan(null);
}}
/>
<p className="onboarding-r6-mode-ultra-note">
{text("Ultra 可在完成首次设置后到设置页开启。")}
</p>
{(modeError || error) && (
<p className="onboarding-r6-mode-error" role="alert">
{text(modeError ?? error ?? "")}
</p>
)}
</section>
</R6ColdStartFrame>
) : null}
</>
)}
{visitedModel && (
<div hidden={setupStep !== "model"}>
@@ -1129,7 +1414,8 @@ export function OnboardingFlow({
generationToken={modelGenerationToken}
isGenerationCurrent={(token) =>
modelGeneration.current === token &&
intentRef.current === "pro" &&
intentRef.current !== "lite" &&
intentRef.current !== null &&
setupStepRef.current === "model"
}
initialProfileId={modelProfileId.current}
@@ -1142,7 +1428,7 @@ export function OnboardingFlow({
void persistModelProfile(profileId)
}
onModelActiveConfirmed={(token, ownerProfileId) =>
void confirmProMode(token, ownerProfileId)
void confirmModelMode(token, ownerProfileId)
}
sourceNames={sourceIds.map(
(id) =>
@@ -1157,10 +1443,11 @@ export function OnboardingFlow({
)}
onBack={() => {
setModeError(null);
leaveModelStep("pro", "mode");
const target = intentRef.current === "ultra" ? "ultra" : "pro";
leaveModelStep(target, "mode");
void queueProgressWrite(() =>
putOnboardingProgress({
mode_intent: "pro",
mode_intent: target,
setup_step: "mode",
model_profile_id: modelProfileId.current,
}),
@@ -1188,7 +1475,7 @@ export function OnboardingFlow({
<>
<R6Button
variant="quiet"
disabled={busy !== null}
disabled={busy !== null || completionBusy}
onClick={() => finishOnce(exitDestination)}
>
{text(
@@ -1197,7 +1484,7 @@ export function OnboardingFlow({
</R6Button>
<R6Button
variant="primary"
disabled={busy !== null}
disabled={busy !== null || completionBusy}
onClick={() => void leave(exitDestination)}
>
{text("重试保存并进入")}
@@ -23,7 +23,17 @@ const { embedding, getModelProfiles, models, step } = vi.hoisted(() => ({
max_concurrency: 4,
context_window: null,
has_api_key: true,
credential_state: "available" as
| "available"
| "missing"
| "unavailable",
is_active: true,
capabilities: {
endpoint_reachable: true,
model_available: true,
embedding_available: true,
structured_output: false,
},
},
],
pendingProbe: null as null | {
@@ -57,7 +67,7 @@ const { embedding, getModelProfiles, models, step } = vi.hoisted(() => ({
supported: false as boolean | null,
planActivation: vi.fn(),
startPlannedSwitch: vi.fn(),
shouldUseLegacyActivation: () => true,
shouldUseLegacyActivation: (() => true) as () => boolean,
clearPlan: vi.fn(),
clearError: vi.fn(),
},
@@ -123,6 +133,7 @@ beforeEach(() => {
models.creationMissing = null;
embedding.current = null;
embedding.supported = false;
embedding.shouldUseLegacyActivation = () => true;
step.current = null;
});
@@ -164,6 +175,32 @@ it("resumes and confirms the active profile explicitly owned by onboarding", asy
expect(enabled).toHaveBeenCalledWith(1, "active-profile");
});
it("does not complete with an active profile whose credential is unavailable", async () => {
models.profiles = [
{
...activeProfile,
credential_state: "unavailable",
},
];
const enabled = vi.fn();
render(
<OnboardingModelController
sourceNames={[]}
initialProfileId="active-profile"
onBack={vi.fn()}
onModelActiveConfirmed={enabled}
/>,
);
await waitFor(() => expect(step.current).not.toBeNull());
expect(enabled).not.toHaveBeenCalled();
await userEvent.click(
screen.getByRole("button", { name: "继续使用当前模型" }),
);
expect(enabled).not.toHaveBeenCalled();
expect(screen.getByText(/模型凭据或连接状态已变化/)).toBeTruthy();
});
it("hydrates a persisted inactive profile without creating a duplicate", async () => {
models.profiles = [{ ...activeProfile, is_active: false }];
const active = vi.fn();
@@ -206,7 +243,7 @@ it("recovers a pending creation and persists its profile id", async () => {
});
it("reconciles a pending probe instead of saving or creating again", async () => {
const inactive = { ...activeProfile, is_active: false };
const inactive = { ...activeProfile, revision: 4, is_active: false };
models.profiles = [inactive];
models.pendingProbe = {
operationId: "probe-operation",
@@ -215,7 +252,8 @@ it("reconciles a pending probe instead of saving or creating again", async () =>
};
models.probeProfile.mockResolvedValueOnce({
profile_id: "active-profile",
profile_revision: 3,
input_profile_revision: 3,
profile_revision: 4,
capabilities: {
endpoint_reachable: true,
model_available: true,
@@ -238,10 +276,76 @@ it("reconciles a pending probe instead of saving or creating again", async () =>
await waitFor(() =>
expect(models.probeProfile).toHaveBeenCalledWith("active-profile"),
);
await waitFor(() =>
expect(screen.getByText("模型连接测试通过,可以启用。")).toBeTruthy(),
);
expect(currentStep().canSaveAndEnable).toBe(true);
expect(models.update).not.toHaveBeenCalled();
expect(models.create).not.toHaveBeenCalled();
});
it("passes the first test when Core advances the tested profile revision", async () => {
const inactive = { ...activeProfile, revision: 1, is_active: false };
models.profiles = [inactive];
models.update.mockResolvedValueOnce(inactive);
models.probeProfile.mockResolvedValueOnce({
profile_id: inactive.profile_id,
input_profile_revision: 1,
profile_revision: 2,
capabilities: {
endpoint_reachable: true,
model_available: true,
embedding_available: true,
},
stages: [{ ok: false }],
});
models.activate.mockResolvedValueOnce({
...inactive,
revision: 2,
is_active: true,
capabilities: {
endpoint_reachable: true,
model_available: true,
embedding_available: true,
},
});
embedding.supported = true;
embedding.current = { writes_paused: false };
embedding.shouldUseLegacyActivation = () => false;
embedding.planActivation.mockResolvedValueOnce({ needed: false });
const confirmed = vi.fn();
render(
<OnboardingModelController
sourceNames={[]}
initialProfileId={inactive.profile_id}
onBack={vi.fn()}
onModelActiveConfirmed={confirmed}
/>,
);
await waitFor(() =>
expect(
(currentStep().profile as { profile_id: string } | null)?.profile_id,
).toBe(inactive.profile_id),
);
await act(async () => {
await (currentStep().onTest as () => Promise<void>)();
});
await waitFor(() =>
expect(screen.getByText("模型连接测试通过,可以启用。")).toBeTruthy(),
);
expect(currentStep().canSaveAndEnable).toBe(true);
expect(currentStep().profile).toMatchObject({ revision: 2 });
expect(getModelProfiles).not.toHaveBeenCalled();
await act(async () => {
await (currentStep().onSaveAndEnable as () => Promise<void>)();
});
expect(embedding.planActivation).toHaveBeenCalledWith(inactive.profile_id, 2);
expect(models.activate).toHaveBeenCalledWith(inactive.profile_id);
expect(confirmed).toHaveBeenCalledWith(1, inactive.profile_id);
});
it("keeps an active resumed profile on the model step while writes are paused", async () => {
embedding.supported = true;
embedding.current = { writes_paused: true };
@@ -1,16 +1,15 @@
import type {
ModelProbeResult,
ModelProfile,
ModelProfileInput,
ModelProfilePatch,
} from "@one-memory/contracts";
import { useEffect, useRef, useState } from "react";
import { getModelProfiles } from "./api";
import { useCallback, useEffect, useRef, useState } from "react";
import { EmbeddingSwitchConfirmDialog } from "./EmbeddingSwitchExperience";
import {
presetForGeneration,
validateGenerationModel,
} from "./GenerationModelPrimitives";
import { modelProfileReady, profileFromProbe } from "./model-profile-readiness";
import {
type OnboardingModelDraft,
OnboardingModelStep,
@@ -41,23 +40,6 @@ const initial: OnboardingModelDraft = {
context_window: "",
};
export function probeMatchesProfile(
original: ModelProfile,
current: ModelProfile | undefined,
probe: ModelProbeResult | undefined | null,
): current is ModelProfile {
return Boolean(
current &&
probe &&
probe.profile_id === original.profile_id &&
probe.profile_revision === current.revision &&
probe.capabilities.endpoint_reachable &&
probe.capabilities.model_available &&
probe.capabilities.embedding_available &&
probe.stages.every((stage) => stage.ok),
);
}
export function OnboardingModelController({
sourceNames,
importedConversationCount = 0,
@@ -112,6 +94,17 @@ export function OnboardingModelController({
>(),
);
const autoConfirmed = useRef<string | null>(null);
const confirmReadyProfile = useCallback(
(profile: ModelProfile, owner = generationToken) => {
if (!isGenerationCurrent(owner) || !modelProfileReady(profile)) {
setError("模型凭据或连接状态已变化,请重新测试后再完成首次设置。");
return false;
}
onModelActiveConfirmed?.(owner, profile.profile_id);
return true;
},
[generationToken, isGenerationCurrent, onModelActiveConfirmed],
);
useEffect(() => {
const profile = initialProfileId
@@ -152,21 +145,19 @@ export function OnboardingModelController({
useEffect(() => {
if (
!saved?.is_active ||
!isGenerationCurrent(generationToken) ||
(embeddingSwitch.supported !== false && !embeddingSwitch.current) ||
embeddingSwitch.current?.writes_paused
)
return;
const key = `${generationToken}:${saved.profile_id}`;
if (autoConfirmed.current === key) return;
autoConfirmed.current = key;
onModelActiveConfirmed?.(generationToken, saved.profile_id);
if (confirmReadyProfile(saved, generationToken))
autoConfirmed.current = key;
}, [
embeddingSwitch.current,
embeddingSwitch.supported,
confirmReadyProfile,
generationToken,
isGenerationCurrent,
onModelActiveConfirmed,
saved,
]);
@@ -182,8 +173,7 @@ export function OnboardingModelController({
flight.current = false;
setSwitchPendingJobId(null);
setPhase("idle");
if (saved && isGenerationCurrent(generationToken))
onModelActiveConfirmed?.(generationToken, saved.profile_id);
if (saved) confirmReadyProfile(saved, generationToken);
} else if (finished.state === "rolled_back") {
flight.current = false;
setSwitchPendingJobId(null);
@@ -192,9 +182,8 @@ export function OnboardingModelController({
}
}, [
embeddingSwitch.current?.last_finished,
confirmReadyProfile,
generationToken,
isGenerationCurrent,
onModelActiveConfirmed,
saved,
switchPendingJobId,
]);
@@ -338,11 +327,10 @@ export function OnboardingModelController({
setPhase("testing");
const result = await models.probeProfile(profile.profile_id);
if (!isGenerationCurrent(owner)) return;
const current = (await getModelProfiles()).find(
(item) => item.profile_id === profile.profile_id,
const current = profileFromProbe(profile, result);
const passed = Boolean(
current && modelProfileReady(current, result ?? null),
);
if (!isGenerationCurrent(owner)) return;
const passed = probeMatchesProfile(profile, current, result);
if (current) setSaved(current);
setProbe({
state: passed ? "passed" : "failed",
@@ -378,11 +366,10 @@ export function OnboardingModelController({
try {
const result = await models.probeProfile(saved.profile_id);
if (!isGenerationCurrent(owner)) return;
const current = (await getModelProfiles()).find(
(item) => item.profile_id === saved.profile_id,
const current = profileFromProbe(saved, result);
const passed = Boolean(
current && modelProfileReady(current, result ?? null),
);
if (!isGenerationCurrent(owner)) return;
const passed = probeMatchesProfile(saved, current, result);
if (current) setSaved(current);
setProbe({
state: passed ? "passed" : "failed",
@@ -408,8 +395,7 @@ export function OnboardingModelController({
setError("模型写入暂时关闭。请等待索引维护完成后重试。");
return;
}
if (isGenerationCurrent(generationToken))
onModelActiveConfirmed?.(generationToken, saved.profile_id);
confirmReadyProfile(saved, generationToken);
return;
}
const owner = generationToken;
@@ -433,9 +419,15 @@ export function OnboardingModelController({
}
const active = await models.activate(saved.profile_id);
if (!isGenerationCurrent(owner)) return;
if (!active?.is_active) throw new Error("model_activation_unconfirmed");
if (
!active?.is_active ||
active.profile_id !== saved.profile_id ||
active.revision !== saved.revision ||
!modelProfileReady(active)
)
throw new Error("model_activation_unconfirmed");
trace.done("succeeded");
onModelActiveConfirmed?.(owner, active.profile_id);
confirmReadyProfile(active, owner);
} catch {
trace.done("unknown", { reason: "model_enable_unconfirmed" });
setError("模型未能确认启用,请在设置中核对。");
+1
View File
@@ -169,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();
@@ -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>
+265
View File
@@ -0,0 +1,265 @@
import { Check, ChevronDown, Clock3 } from "lucide-react";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button } from "./R6Controls";
const HOURS = Array.from({ length: 24 }, (_, value) =>
value.toString().padStart(2, "0"),
);
const MINUTES = Array.from({ length: 60 }, (_, value) =>
value.toString().padStart(2, "0"),
);
function splitTime(value: string): [string, string] {
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value);
return match ? [match[1], match[2]] : ["00", "00"];
}
export function TimePicker({
id,
label,
value,
disabled = false,
onChange,
}: {
id: string;
label: string;
value: string;
disabled?: boolean;
onChange(value: string): Promise<boolean>;
}) {
const { locale, text } = useOnboardingTranslation();
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [placement, setPlacement] = useState<"above" | "below">("above");
const [hour, setHour] = useState(() => splitTime(value)[0]);
const [minute, setMinute] = useState(() => splitTime(value)[1]);
const root = useRef<HTMLDivElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const panel = useRef<HTMLElement>(null);
const hourSelect = useRef<HTMLSelectElement>(null);
const minuteSelect = useRef<HTMLSelectElement>(null);
const restoreTriggerFocus = useRef(false);
const restoreSaveFocus = useRef(false);
const savingRef = useRef(false);
savingRef.current = saving;
const panelId = `${id}-picker`;
const close = useCallback((restoreFocus = true) => {
setOpen(false);
restoreTriggerFocus.current = restoreFocus;
}, []);
function show() {
const [nextHour, nextMinute] = splitTime(value);
setHour(nextHour);
setMinute(nextMinute);
setOpen(true);
}
useEffect(() => {
if (!open) return;
const closeOutside = (event: MouseEvent) => {
if (savingRef.current) return;
const target = event.target as Element | null;
const label = target?.closest("label");
if (label instanceof HTMLLabelElement && label.htmlFor === id) return;
if (!root.current?.contains(target)) close(false);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== "Escape" || savingRef.current) return;
event.preventDefault();
close();
};
document.addEventListener("mousedown", closeOutside);
document.addEventListener("keydown", closeOnEscape);
hourSelect.current?.focus();
hourSelect.current?.selectedOptions[0]?.scrollIntoView?.({
block: "nearest",
});
minuteSelect.current?.selectedOptions[0]?.scrollIntoView?.({
block: "nearest",
});
return () => {
document.removeEventListener("mousedown", closeOutside);
document.removeEventListener("keydown", closeOnEscape);
};
}, [close, id, open]);
useEffect(() => {
if (disabled && open && !saving) close(false);
if (!disabled && !open && restoreTriggerFocus.current) {
restoreTriggerFocus.current = false;
trigger.current?.focus();
}
if (!disabled && open && restoreSaveFocus.current) {
restoreSaveFocus.current = false;
panel.current
?.querySelector<HTMLButtonElement>("[data-time-picker-save]")
?.focus();
}
if (saving && open) panel.current?.focus();
}, [close, disabled, open, saving]);
useLayoutEffect(() => {
if (!open) return;
const place = () => {
const anchor = trigger.current?.getBoundingClientRect();
const picker = panel.current?.getBoundingClientRect();
if (!anchor || !picker) return;
const scroller = trigger.current?.closest(
".app-content, .onboarding-frame",
);
const bounds = scroller?.getBoundingClientRect();
const topEdge = (bounds ? Math.max(0, bounds.top) : 0) + 12;
const bottomEdge =
(bounds
? Math.min(window.innerHeight, bounds.bottom)
: window.innerHeight) - 12;
const above = anchor.top - topEdge;
const below = bottomEdge - anchor.bottom;
setPlacement(
above >= picker.height || above >= below ? "above" : "below",
);
};
place();
window.addEventListener("resize", place);
window.addEventListener("scroll", place, true);
return () => {
window.removeEventListener("resize", place);
window.removeEventListener("scroll", place, true);
};
}, [open]);
async function save() {
const next = `${hour}:${minute}`;
if (next === value) {
close();
return;
}
setSaving(true);
const saved = await onChange(next);
setSaving(false);
if (saved) close();
else restoreSaveFocus.current = true;
}
return (
<div className="r6-time-picker" ref={root}>
<button
ref={trigger}
id={id}
type="button"
className="r6-time-picker-trigger"
aria-label={
locale.startsWith("zh") ? `${label},${value}` : `${label}: ${value}`
}
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={panelId}
disabled={disabled}
onClick={() => (open ? close() : show())}
>
<span>{value}</span>
<span className="r6-time-picker-icons" aria-hidden="true">
<Clock3 size={15} />
<ChevronDown size={14} />
</span>
</button>
{open ? (
<section
ref={panel}
id={panelId}
className="r6-time-picker-panel"
role="dialog"
aria-modal="true"
aria-label={label}
tabIndex={-1}
data-placement={placement}
onKeyDown={(event) => {
if (event.key !== "Tab") return;
const controls = Array.from(
event.currentTarget.querySelectorAll<HTMLElement>(
"select:not(:disabled), button:not(:disabled)",
),
);
const first = controls[0];
const last = controls.at(-1);
if (!first || !last) {
event.preventDefault();
event.currentTarget.focus();
return;
}
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}}
>
<div className="r6-time-picker-columns">
<label className="r6-time-picker-column">
<span aria-hidden="true">HH</span>
<select
ref={hourSelect}
size={6}
aria-label={`${label} HH`}
value={hour}
disabled={saving}
onChange={(event) => setHour(event.target.value)}
>
{HOURS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<span className="r6-time-picker-separator" aria-hidden="true">
:
</span>
<label className="r6-time-picker-column">
<span aria-hidden="true">MM</span>
<select
ref={minuteSelect}
size={6}
aria-label={`${label} MM`}
value={minute}
disabled={saving}
onChange={(event) => setMinute(event.target.value)}
>
{MINUTES.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
</div>
<footer>
<R6Button disabled={saving} onClick={() => close()}>
{text("取消")}
</R6Button>
<R6Button
variant="primary"
pending={saving}
data-time-picker-save
onClick={() => void save()}
>
<Check size={15} aria-hidden="true" />
{text("保存修改")}
</R6Button>
</footer>
</section>
) : null}
</div>
);
}
@@ -0,0 +1,63 @@
import type { EvolveSchedule } from "@one-memory/contracts";
import { useId } from "react";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Switch } from "./R6Controls";
import { TimePicker } from "./TimePicker";
export function UltraEvolveScheduleFields({
schedule,
headingId,
disabled = false,
onEnabledChange,
onTimeChange,
}: {
schedule: EvolveSchedule;
headingId?: string;
disabled?: boolean;
onEnabledChange(enabled: boolean): void;
onTimeChange(time: string): Promise<boolean>;
}) {
const { text } = useOnboardingTranslation();
const id = useId();
return (
<>
<div className="r6-evolve-heading">
<h3 id={headingId}>{text("记忆整合(Evolve)")}</h3>
<p>{text("定期重新整理已有记忆,补充更稳定的关联和结论。")}</p>
</div>
<div className="r6-evolve-setting">
<div className="r6-evolve-setting-copy">
<h3>{text("每天自动整合")}</h3>
<p>
{text(
schedule.enabled
? "每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。"
: "已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。",
).replace("{time}", schedule.time)}
</p>
</div>
<R6Switch
label={text("每天自动整合")}
checked={schedule.enabled}
disabled={disabled}
onCheckedChange={onEnabledChange}
/>
{schedule.enabled ? (
<div className="r6-evolve-time">
<div>
<label htmlFor={`${id}-time`}>{text("执行时间")}</label>
<p>{text("使用这台设备的本地时间。")}</p>
</div>
<TimePicker
id={`${id}-time`}
label={text("执行时间")}
value={schedule.time}
disabled={disabled}
onChange={onTimeChange}
/>
</div>
) : null}
</div>
</>
);
}
+4
View File
@@ -32,6 +32,9 @@ it("lists raw conversations for the selected application", async () => {
session_id: undefined,
offset: 20,
limit: 20,
unit: "turn",
paging: undefined,
cursor: undefined,
},
signal,
});
@@ -49,6 +52,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,
},
+16 -1
View File
@@ -986,16 +986,30 @@ export async function getSdkRaw({
sessionId,
offset = 0,
limit = 100,
unit = "turn",
paging,
cursor,
signal,
}: {
source?: string;
sessionId?: string;
offset?: number;
limit?: number;
unit?: "raw" | "turn";
paging?: "offset" | "cursor";
cursor?: string | null;
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,
paging,
cursor: cursor || undefined,
},
signal,
});
return response.data;
@@ -1011,6 +1025,7 @@ export async function searchSdkRaw(
query,
kinds: ["raw"],
...(options.source ? { include_sources: [options.source] } : {}),
raw_unit: "turn",
limit: 20,
character_budget: 12_000,
},
@@ -42,6 +42,8 @@
margin-bottom: 24px;
}
.r6-home-overview {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 346px;
padding: 26px;
@@ -150,6 +152,7 @@
padding: 8px 0;
}
.r6-home-activity {
margin-top: auto;
border-top: 1px solid #d5dfe8;
padding-top: 12px;
}
+8
View File
@@ -986,6 +986,14 @@ button.map-tool {
background: #fff;
box-shadow: var(--shadow-pop);
}
.memory-graph-fallback-topic {
display: block;
width: 100%;
margin-block: 6px;
padding: 8px 14px;
color: var(--tx2);
overflow-wrap: anywhere;
}
.graph-labels {
position: absolute;
inset: 0;
+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,
}
: {
+232
View File
@@ -0,0 +1,232 @@
import type { MemoryModePlan, MemoryPipelineMode } from "@one-memory/contracts";
import { useEffect, useRef } from "react";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button } from "./R6Controls";
export const MODE_COPY: Record<
MemoryPipelineMode,
{ title: string; description: string }
> = {
lite: {
title: "基础记忆(lite)",
description: "保存原始对话 · 不调用生成模型 · 搜索原始会话",
},
pro: {
title: "智能记忆(pro)",
description: "沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆",
},
ultra: {
title: "深度记忆(ultra)",
description: "智能记忆 + 知识图谱 · 可自动 Evolve · 增强关联召回",
},
};
export const BLOCKER_COPY: Record<MemoryModePlan["blockers"][number], string> =
{
active_model_required: "使用 pro 或 ultra 前需要先配置并启用生成模型。",
embedding_maintenance_in_progress:
"正在检查或切换向量模型,完成后才能切换记忆模式。",
memory_lifecycle_busy: "记忆维护正在进行,完成后才能切换模式。",
graph_store_unavailable: "当前安装缺少知识图谱组件,无法开启 ultra。",
};
export function memoryModePlanRows(plan: MemoryModePlan) {
const rows = [
{
label: "新对话",
content:
plan.capabilities.content_plane === "raw"
? "之后只保存原始对话,不再生成新记忆。"
: plan.capabilities.graph_enabled
? "新对话会整理成记忆并写入知识图谱。"
: "新对话会由模型整理成记忆。",
},
{
label: "模型调用与费用",
content: plan.capabilities.generation_required
? plan.capabilities.evolve_available
? "会调用当前生成模型;自动整合可能产生额外费用。切换本身不会立即整合。"
: "会调用当前生成模型,可能产生服务商费用。"
: "不调用生成模型,不再产生这部分费用。",
},
{
label: "搜索范围",
content:
plan.capabilities.content_plane === "raw"
? "切换后搜索原始对话。"
: plan.capabilities.graph_enabled
? "搜索沉淀记忆,并用知识图谱补充关联。"
: "搜索沉淀后的记忆。",
},
];
if (plan.effects.includes("earlier_raw_not_extracted"))
rows.push({
label: "已有数据",
content: "已经处理完的旧对话不会补做;原始对话全部保留。",
});
if (plan.effects.includes("higher_mode_memories_hidden"))
rows.push({
label: "已有数据",
content:
plan.effective_target === "pro"
? "普通沉淀记忆仍可使用;深度记忆新增的更高层记忆和图谱数据继续保留,但在智能记忆下不显示、不参与召回。原始对话不会删除。"
: "已有记忆继续保留,但在基础记忆下不显示、不参与召回;原始对话不会删除。",
});
if (plan.effects.includes("existing_memories_added_to_graph"))
rows.push({
label: "知识图谱",
content: "已有记忆会在后台加入知识图谱,期间仍可使用。",
});
if (plan.effects.includes("evolve_stopped"))
rows.push({
label: "每天自动整合",
content: "每天自动整合会停止,已经生成的数据不会删除。",
});
if (plan.capabilities.evolve_available)
rows.push({
label: "切换后",
content: "可设置每天自动整合时间,也可以随时立即整合一次。",
});
return rows;
}
export function MemoryModeConfirmDialog({
mode,
plan,
busy,
error,
opener,
allowedBlockers = [],
onCancel,
onConfirm,
}: {
mode: MemoryPipelineMode;
plan: MemoryModePlan;
busy: boolean;
error?: string;
opener?: HTMLElement | null;
allowedBlockers?: readonly MemoryModePlan["blockers"][number][];
onCancel(): void;
onConfirm(): void;
}) {
const { text } = useOnboardingTranslation();
const panel = useRef<HTMLElement>(null);
const busyRef = useRef(busy);
const onCancelRef = useRef(onCancel);
busyRef.current = busy;
onCancelRef.current = onCancel;
const blocking = plan.blockers.filter(
(blocker) => !allowedBlockers.includes(blocker),
);
useEffect(() => {
const element = panel.current;
if (!element) return;
const inertSiblings = new Map<HTMLElement, boolean>();
let branch: HTMLElement = element.parentElement ?? element;
while (branch.parentElement) {
for (const sibling of branch.parentElement.children) {
if (!(sibling instanceof HTMLElement) || sibling === branch) continue;
inertSiblings.set(sibling, sibling.inert);
sibling.inert = true;
}
branch = branch.parentElement;
if (branch === document.body) break;
}
const controls = () =>
Array.from(
element.querySelectorAll<HTMLElement>("button:not(:disabled)"),
);
controls()[0]?.focus();
const handleKey = (event: KeyboardEvent) => {
if (event.key === "Escape" && !busyRef.current) {
event.preventDefault();
onCancelRef.current();
return;
}
if (event.key !== "Tab") return;
const items = controls();
const first = items[0];
const last = items.at(-1);
if (!first || !last) {
event.preventDefault();
element.focus();
} else if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
element.addEventListener("keydown", handleKey);
return () => {
element.removeEventListener("keydown", handleKey);
for (const [sibling, wasInert] of inertSiblings) sibling.inert = wasInert;
if (opener?.isConnected) opener.focus();
};
}, [opener]);
useEffect(() => {
if (busy) panel.current?.focus();
}, [busy]);
return (
<div className="r6-settings-dialog-layer" role="presentation">
<section
ref={panel}
className="r6-settings-dialog r6-mode-confirm-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="memory-mode-confirm-title"
tabIndex={-1}
>
<h2 id="memory-mode-confirm-title">
{text("切换到 {mode}?").replace(
"{mode}",
text(MODE_COPY[mode].title),
)}
</h2>
<dl className="r6-mode-confirm-effects">
{memoryModePlanRows(plan).map((row) => (
<div key={`${row.label}:${row.content}`}>
<dt>{text(row.label)}</dt>
<dd>{text(row.content)}</dd>
</div>
))}
</dl>
{plan.blockers.map((blocker) => (
<p
className={
blocking.includes(blocker)
? "r6-settings-error"
: "r6-settings-note"
}
role={blocking.includes(blocker) ? "alert" : "status"}
key={blocker}
>
{text(BLOCKER_COPY[blocker])}
</p>
))}
{error ? (
<p className="r6-settings-error" role="alert">
{text(error)}
</p>
) : null}
<footer>
<R6Button disabled={busy} onClick={onCancel}>
{text("取消")}
</R6Button>
<R6Button
variant="primary"
pending={busy}
disabled={blocking.length > 0}
onClick={onConfirm}
>
{text("切换模式")}
</R6Button>
</footer>
</section>
</div>
);
}
@@ -0,0 +1,79 @@
import type { ModelProbeResult, ModelProfile } from "@one-memory/contracts";
import { expect, it } from "vitest";
import { modelProfileReady, profileFromProbe } from "./model-profile-readiness";
const profile: ModelProfile = {
profile_id: "saved-1",
name: "Saved model",
provider_kind: "openai_compatible",
base_url: "https://models.example/v1",
model: "model-1",
embedding_model: null,
embedding_provider_kind: null,
embedding_base_url: null,
embedding_dimensions: null,
context_window: null,
disable_thinking: false,
has_api_key: true,
timeout_seconds: 60,
max_concurrency: 1,
revision: 1,
is_active: false,
created_at: "2026-09-14T00:00:00Z",
updated_at: "2026-09-14T00:00:00Z",
capabilities: {
endpoint_reachable: false,
model_available: false,
embedding_available: false,
structured_output: false,
},
};
function probe(
inputRevision: number | null | undefined = 1,
outputRevision = 2,
): ModelProbeResult {
return {
profile_id: profile.profile_id,
input_profile_revision: inputRevision,
profile_revision: outputRevision,
stages: [],
capabilities: {
endpoint_reachable: true,
model_available: true,
embedding_available: true,
structured_output: false,
},
};
}
it("accepts a measured one-revision bump before or after profile refresh", () => {
const receipt = probe();
const beforeRefresh = profileFromProbe(profile, receipt);
const afterRefresh = profileFromProbe({ ...profile, revision: 2 }, receipt);
expect(beforeRefresh).toMatchObject({ revision: 2 });
expect(afterRefresh).toMatchObject({ revision: 2 });
expect(modelProfileReady(beforeRefresh as ModelProfile, receipt)).toBe(true);
});
it("rejects a bump without input evidence, a skipped revision and another profile", () => {
expect(profileFromProbe(profile, probe(null, 2))).toBeNull();
expect(profileFromProbe(profile, probe(1, 3))).toBeNull();
expect(
profileFromProbe(profile, {
...probe(),
profile_id: "another-profile",
}),
).toBeNull();
});
it("keeps unavailable credentials from becoming ready", () => {
const receipt = probe();
const tested = profileFromProbe(
{ ...profile, credential_state: "unavailable" },
receipt,
);
expect(tested).not.toBeNull();
expect(modelProfileReady(tested as ModelProfile, receipt)).toBe(false);
});
@@ -0,0 +1,48 @@
import type { ModelProbeResult, ModelProfile } from "@one-memory/contracts";
export function profileFromProbe(
profile: ModelProfile,
probe: ModelProbeResult | null | undefined,
): ModelProfile | null {
if (!probe || probe.profile_id !== profile.profile_id) return null;
const inputRevision = probe.input_profile_revision;
if (!Number.isSafeInteger(probe.profile_revision)) return null;
if (inputRevision === undefined || inputRevision === null) {
if (probe.profile_revision !== profile.revision) return null;
} else {
if (
!Number.isSafeInteger(inputRevision) ||
(probe.profile_revision !== inputRevision &&
probe.profile_revision !== inputRevision + 1) ||
(profile.revision !== inputRevision &&
profile.revision !== probe.profile_revision)
)
return null;
}
return {
...profile,
revision: probe.profile_revision,
capabilities: probe.capabilities,
};
}
export function modelProfileReady(
profile: ModelProfile,
probe: ModelProbeResult | null = null,
error: string | null = null,
): boolean {
return (
!error &&
(!probe ||
(probe.profile_id === profile.profile_id &&
probe.profile_revision === profile.revision &&
probe.capabilities.endpoint_reachable &&
probe.capabilities.model_available &&
probe.capabilities.embedding_available)) &&
profile.credential_state !== "unavailable" &&
!(profile.has_configured_api_key && !profile.has_api_key) &&
profile.capabilities.endpoint_reachable &&
profile.capabilities.model_available &&
profile.capabilities.embedding_available
);
}
+5
View File
@@ -134,6 +134,11 @@
background: transparent;
color: #4e6478;
}
.model-settings .generation-technical-toggle:hover:not(:disabled) {
border-color: transparent;
background: #e9f0f4;
color: #304757;
}
.model-settings .generation-technical-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
+35 -2
View File
@@ -719,8 +719,8 @@
color: #416e5c;
font-size: 13px;
}
.generation-model-error {
color: #a24646 !important;
.onboarding-model-r6 .generation-model-error {
color: #a24646;
}
.onboarding-model-recovery-actions {
margin-top: 12px;
@@ -728,6 +728,19 @@
.onboarding-model-feedback {
margin-top: 8px;
}
.onboarding-model-r6 .onboarding-r6-foot {
display: grid;
grid-template-columns: minmax(220px, 1fr) auto;
align-items: center;
gap: 20px 32px;
}
.onboarding-model-r6 .onboarding-model-footer-actions {
flex-wrap: nowrap;
margin-left: 0;
}
.onboarding-model-r6 .onboarding-model-footer-actions .r6-button {
min-width: 132px;
}
@media (min-width: 761px) {
.onboarding-model-r6 .onboarding-r6-body {
overflow-y: auto;
@@ -751,6 +764,26 @@
flex-basis: 100%;
}
}
@media (max-width: 900px) {
.onboarding-model-r6 .onboarding-r6-foot {
grid-template-columns: minmax(0, 1fr);
}
.onboarding-model-r6 .onboarding-model-footer-actions {
width: 100%;
flex-wrap: wrap;
}
.onboarding-model-r6 .onboarding-model-footer-actions .r6-button {
flex: 1 1 160px;
}
}
@media (max-width: 600px) {
.onboarding-model-r6 .onboarding-model-footer-actions .r6-button,
.onboarding-model-r6
.onboarding-model-footer-actions
.r6-button[data-variant="primary"] {
flex-basis: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.generation-technical-toggle,
.generation-model-field :is(input, select) {
+38
View File
@@ -612,6 +612,44 @@
line-height: 20px;
text-align: center;
}
.onboarding-r6-evolve-settings {
margin-top: 0;
padding: 18px 20px 4px;
border: 1px solid #cfdae2;
border-radius: 12px;
background: rgb(255 255 255 / 58%);
}
.onboarding-r6-evolve-settings .r6-evolve-heading {
margin-bottom: 2px;
}
.onboarding-r6-evolve-settings .r6-evolve-setting {
grid-template-columns: minmax(0, 1fr) auto;
padding-bottom: 12px;
border-bottom: 0;
}
.onboarding-r6-evolve-settings .r6-evolve-setting-copy {
min-width: 0;
}
.onboarding-r6-evolve-settings .r6-evolve-time {
grid-template-columns: minmax(0, 1fr) 180px;
}
.onboarding-r6-evolve-settings .r6-evolve-time label {
color: #304757;
font-size: 13px;
font-weight: 600;
line-height: 22px;
}
.onboarding-r6-schedule-recovery {
display: grid;
justify-items: center;
gap: 10px;
color: #944242;
font-size: 13px;
line-height: 20px;
}
.onboarding-r6-schedule-recovery p {
margin: 0;
}
.onboarding-r6-mode-error {
min-height: 20px;
margin: -6px 0 0;
+141 -8
View File
@@ -228,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;
@@ -294,7 +295,7 @@
padding-top: 16px;
border-top: 1px solid #e1e8ed;
}
.r6-settings .r6-evolve-time label {
.r6-settings .r6-evolve-time > div > label {
color: #304757;
font-size: 13px;
font-weight: 600;
@@ -303,20 +304,146 @@
.r6-evolve-time p {
margin-top: 1px;
}
.r6-evolve-settings input[type="time"] {
.r6-time-picker {
position: relative;
width: 180px;
}
.r6-time-picker-trigger {
box-sizing: border-box;
width: 180px;
min-height: 44px;
padding: 0 10px;
height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 12px 0 14px;
border: 1px solid #c6d2da;
border-radius: 8px;
color: #304757;
background: #fff;
font:
500 14px / 22px "Noto Sans SC",
"PingFang SC",
sans-serif;
font-variant-numeric: tabular-nums;
cursor: pointer;
transition:
border-color 140ms ease-out,
background-color 140ms ease-out,
box-shadow 140ms ease-out;
}
.r6-evolve-settings input[type="time"]:focus-visible {
.r6-time-picker-trigger:hover:not(:disabled) {
border-color: #9eafbb;
background: #f8fafb;
}
.r6-time-picker-trigger[aria-expanded="true"] {
border-color: #7892a6;
box-shadow: 0 0 0 3px rgb(85 124 147 / 12%);
}
.r6-time-picker-trigger:focus-visible {
outline: 2px solid var(--r6-focus);
outline-offset: 2px;
}
.r6-time-picker-trigger:disabled {
color: #71808b;
background: #edf0f3;
cursor: default;
}
.r6-time-picker-trigger:active:not(:disabled) {
transform: none;
}
.r6-time-picker-icons {
display: inline-flex;
align-items: center;
gap: 5px;
color: #607888;
}
.r6-time-picker-trigger[aria-expanded="true"]
.r6-time-picker-icons
svg:last-child {
transform: rotate(180deg);
}
.r6-time-picker-panel {
box-sizing: border-box;
position: absolute;
z-index: 1200;
right: 0;
bottom: calc(100% + 8px);
width: min(280px, calc(100vw - 32px));
padding: 14px;
border: 1px solid #c6d2da;
border-radius: 10px;
background: #fff;
box-shadow: 0 14px 36px rgb(48 71 87 / 16%);
}
.r6-time-picker-panel[data-placement="below"] {
top: calc(100% + 8px);
bottom: auto;
}
.r6-time-picker-columns {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: end;
gap: 8px;
}
.r6-time-picker-column {
display: grid;
gap: 6px;
color: #5e6e7a;
font-size: 11px;
font-weight: 600;
line-height: 18px;
text-align: center;
}
.r6-time-picker-columns select {
box-sizing: border-box;
width: 100%;
height: 174px;
padding: 4px;
border: 1px solid #d5dfe8;
border-radius: 7px;
color: #304757;
background: #f8fafb;
font:
500 14px / 24px "Noto Sans SC",
"PingFang SC",
sans-serif;
font-variant-numeric: tabular-nums;
scrollbar-width: thin;
scrollbar-color: #b6c3cc transparent;
}
.r6-time-picker-columns select:focus-visible {
outline: 2px solid var(--r6-focus);
outline-offset: 1px;
}
.r6-time-picker-columns option {
min-height: 28px;
padding: 5px 8px;
border-radius: 5px;
}
.r6-time-picker-columns option:checked {
color: #304757;
background: #dfe9ef;
}
.r6-time-picker-separator {
align-self: center;
margin-top: 22px;
color: #7892a6;
font-size: 18px;
font-weight: 600;
}
.r6-time-picker-panel footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e1e8ed;
flex-wrap: wrap;
}
.r6-time-picker-panel footer .r6-button {
min-width: 68px;
}
.r6-state-recovery {
display: flex;
align-items: center;
@@ -481,7 +608,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;
@@ -1057,9 +1185,14 @@
.r6-evolve-time {
gap: 8px;
}
.r6-evolve-settings input[type="time"] {
.r6-time-picker,
.r6-time-picker-trigger {
width: min(220px, 100%);
}
.r6-time-picker-panel {
right: auto;
left: 0;
}
.r6-state-recovery {
align-items: flex-start;
flex-direction: column;
+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");
});
});
+20 -5
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",
@@ -68,6 +72,11 @@ function pageFor(
total: number,
offset: number,
limit: number,
cursor?: {
next_cursor?: string | null;
has_more?: boolean;
total_exact?: boolean;
},
): MemoryLibraryPage {
const sourceIds: Record<string, string[]> = {};
const sourceTime: Record<
@@ -75,23 +84,25 @@ 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),
total,
offset,
limit,
...cursor,
source_ids_by_memory: sourceIds,
source_time_by_memory: sourceTime,
search_window: null,
@@ -102,7 +113,11 @@ function pageFor(
}
export function rawLibraryPage(page: SdkRawPage): MemoryLibraryPage {
return pageFor(page.items, page.total, page.offset, page.limit);
return pageFor(page.items, page.total, page.offset, page.limit, {
next_cursor: page.next_cursor,
has_more: page.has_more,
total_exact: page.total_exact,
});
}
export function rawSearchPage(items: SdkRawContextItem[]): MemoryLibraryPage {
+23 -7
View File
@@ -318,22 +318,38 @@ it("records a failed test only for the attempted revision and rejects a mismatch
expect(result.current.profiles[0].capabilities).toEqual(profile.capabilities);
});
it("shows a probe-induced new revision only after the matching profile refresh", async () => {
it("commits a probe-induced revision before the follow-up refresh returns", async () => {
vi.mocked(api.getModelProfiles).mockResolvedValueOnce([profile]);
const { result } = renderHook(useModelProfiles);
await waitFor(() => expect(result.current.profiles).toHaveLength(1));
const updated = { ...profile, revision: 2, embedding_dimensions: 512 };
const updated = {
...profile,
revision: 2,
embedding_dimensions: 512,
capabilities: {
...profile.capabilities,
endpoint_reachable: true,
model_available: true,
embedding_available: true,
},
};
const receipt = {
...probeResult(updated),
input_profile_revision: profile.revision,
};
const delayed = deferred<ModelProfile[]>();
vi.mocked(api.getModelProfiles).mockReturnValueOnce(delayed.promise);
vi.mocked(api.probeModelProfile).mockResolvedValueOnce(probeResult(updated));
vi.mocked(api.probeModelProfile).mockResolvedValueOnce(receipt);
await act(async () => {
await result.current.probeProfile(profile.profile_id);
});
expect(result.current.probeFor(profile)).toBeNull();
expect(result.current.profiles[0]).toMatchObject({
revision: 2,
capabilities: receipt.capabilities,
});
expect(result.current.probeFor(result.current.profiles[0])).toEqual(receipt);
await act(async () => {
delayed.resolve([updated]);
});
expect(result.current.probeFor(result.current.profiles[0])).toEqual(
probeResult(updated),
);
expect(result.current.probeFor(result.current.profiles[0])).toEqual(receipt);
});
+2 -6
View File
@@ -19,6 +19,7 @@ import {
updateModelProfile,
} from "./api";
import { modelErrorText } from "./model-error-text";
import { profileFromProbe } from "./model-profile-readiness";
import {
clearPendingModelCreation,
readPendingModelCreation,
@@ -314,12 +315,7 @@ export function useModelProfiles() {
})]: result,
}));
setProfiles((previous) =>
previous.map((item) =>
item.profile_id === result.profile_id &&
item.revision === result.profile_revision
? { ...item, capabilities: result.capabilities }
: item,
),
previous.map((item) => profileFromProbe(item, result) ?? item),
);
},
(reason) =>
+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. |
@@ -412,16 +412,18 @@ mode. A change rebuilds the SDK handles the same way. Contract:
The App's product rules are:
- A new installation explicitly chooses lite or pro. Choosing lite persists
the mode and completes onboarding without a generation model. Choosing pro
opens model setup and completes onboarding only after activation succeeds.
- A new installation explicitly chooses lite, pro or ultra. Choosing lite
persists the mode and completes onboarding without a generation model.
Choosing pro or ultra opens model setup when no model is active and completes
onboarding only after activation succeeds.
- Lite always ingests authorized imported and live conversations into L1 raw,
even when model sedimentation is paused. That pause controls generation-model
work in pro/ultra; it never turns raw conversation storage off.
- Existing installations keep the default pro behaviour; an absent
`memory_mode` is never inferred from model availability.
- Ultra is enabled only from Settings. Its nightly schedule is on by default
at 03:00 local time, and Settings also exposes a manual evolve action.
- Ultra's nightly schedule is configured during first-run selection or later
from Settings. It is on by default at 03:00 local time. Settings also exposes
a manual evolve action; onboarding does not run evolve immediately.
- The memory page shows raw conversations in lite and advanced memories in
pro/ultra. Higher-mode memories remain stored while lite is active but are
neither shown nor served by lite search.
+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;
+19 -1
View File
@@ -259,7 +259,7 @@ export interface ConnectSourcesResponse {
request_id?: string | null;
}
export type OnboardingModeIntent = "lite" | "pro";
export type OnboardingModeIntent = "lite" | "pro" | "ultra";
export type OnboardingSetupStep = "review" | "mode" | "model";
export interface OnboardingProgressUpdate {
@@ -510,6 +510,10 @@ export interface MemoryLibraryPage {
total: number;
offset: number;
limit: number;
/** Cursor paging: opaque continuation for a page whose exact total is not read. */
next_cursor?: string | null;
has_more?: boolean;
total_exact?: boolean;
/** Active evidence sources for each returned memory, from the same authorized page snapshot. */
source_ids_by_memory?: Record<string, string[]>;
/** When each returned memory's cited conversation happened (D96). `import`
@@ -675,7 +679,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 +712,10 @@ export interface SdkRawPage {
total: number;
limit: number;
offset: number;
unit?: "raw" | "turn";
next_cursor?: string | null;
has_more?: boolean;
total_exact?: boolean;
/** A time range was applied to this page in Core (the SDK list has none). */
time_filtered?: boolean;
}
@@ -1112,6 +1125,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. */
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff. Show more