Compare commits

...
Author SHA1 Message Date
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 413b9033f9 fix(memhub): inline model configuration panels
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 15:49:20 +08:00
kristinheandCursor 0953c5af35 fix(memhub): keep durable write drain alive
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 15:22:29 +08:00
kristinheandCursor 470ea4459a fix(memhub): keep home status copy compact
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 14:53:33 +08:00
kristinhe1111 b58e95fdae Merge pull request #305 from Tencent-Hunyuan/chore/memhub-build-40
chore(release): MemHub 1.0.6, build 40
2026-11-04 13:27:01 +08:00
kristinheandCursor 42b577f6f8 chore(release): bump MemHub build to 40
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 13:16:08 +08:00
kristinhe1111 2554fb3b96 Merge pull request #304 from Tencent-Hunyuan/feat/memhub-onboarding-mode-model
feat(memhub): 重构冷启动记忆模式与模型配置
2026-11-04 13:12:38 +08:00
kristinheandCursor e32dc987c2 feat(memhub): redesign onboarding mode setup
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 12:00:46 +08:00
kristinhe1111 598e83525e Merge pull request #298 from Tencent-Hunyuan/feat/memhub-mode-projection
feat(memhub): project home and settings by memory mode
2026-11-04 09:29:48 +08:00
kristinhe 01ace26d08 Merge remote-tracking branch 'origin/main' into feat/memhub-mode-projection
Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	memhub/apps/core/src/one_memory/api.py
#	memhub/apps/core/src/one_memory/sdk_write_processor.py
#	memhub/apps/core/tests/test_memory_mode_and_evolve.py
#	memhub/apps/web/src/ApplicationsHome.tsx
#	memhub/apps/web/src/MemorySpace.tsx
2026-11-04 03:17:38 +08:00
kristinheandCursor ba2f647a30 feat(memhub): project home by memory mode
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 02:58:32 +08:00
kristinhe1111 82d9514d62 Merge pull request #297 from Tencent-Hunyuan/fix/memhub-lite-raw-conversations
fix(memhub): preserve raw conversations in lite mode
2026-11-04 01:22:04 +08:00
kristinhe1111 369351719d Merge pull request #295 from Tencent-Hunyuan/fix/memhub-model-center-main
fix(memhub): land Model Center on main
2026-11-04 00:28:38 +08:00
kristinheandCursor 1429ba2d19 fix(memhub): close model center settings gaps
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 00:22:25 +08:00
kristinheandCursor 4e7d752980 feat(memhub): add lite manual raw input
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 00:05:36 +08:00
kristinheandCursor fb13a6fda2 fix(memhub): keep mode-specific memory views consistent
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 23:20:41 +08:00
kristinheandCursor 6ff8b3a3b4 fix(memhub): simplify topic node details
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 22:49:47 +08:00
kristinheandCursor 17441d368c fix(memhub): harden settings state recovery
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 22:39:47 +08:00
kristinheandCursor 89d29c9c41 feat(memhub): redesign memory mode settings
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 22:27:00 +08:00
kristinheandCursor 5271bddce3 fix(memhub): preserve raw conversations in lite mode
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 22:18:12 +08:00
kristinhe eac795b2d2 Merge remote-tracking branch 'origin/main' into fix/memhub-model-center-main 2026-11-03 20:55:54 +08:00
kristinhe1111 d938f1d5b2 Merge pull request #294 from Tencent-Hunyuan/chore/memhub-build-39
chore(release): MemHub 1.0.6, build 39
2026-11-03 20:05:08 +08:00
kristinheandCursor 8cee64c813 chore(release): bump MemHub build number to 39
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 19:59:27 +08:00
kristinhe1111 1ffcc9eb57 Merge pull request #292 from Tencent-Hunyuan/feat/memhub-mode-ui
feat(memhub): complete memory mode app lifecycle
2026-11-03 19:43:30 +08:00
kristinhe 23b74f91bc Merge remote-tracking branch 'origin/feat/memhub-mode-ui' into feat/memhub-model-center 2026-11-03 19:41:41 +08:00
kristinheandCursor b36452d110 fix(memhub): reset mode state and satisfy lint
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 19:36:42 +08:00
kristinhe a8b92d21e1 Merge remote-tracking branch 'origin/feat/memhub-mode-ui' into feat/memhub-model-center 2026-11-03 19:35:22 +08:00
kristinheandCursor e58a7c0d14 fix(memhub): polish mode selector and raw transitions
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 19:32:56 +08:00
kristinheandCursor 1663c9424e fix(memhub): align model center with R6 visuals
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 19:31:00 +08:00
kristinheandCursor fba9d8cf26 fix(memhub): align lite and mode settings UI
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 19:30:43 +08:00
kristinhe f754f8bc0f Merge remote-tracking branch 'origin/feat/memhub-mode-ui' into feat/memhub-model-center
Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	memhub/apps/web/src/OnboardingModelController.tsx
#	memhub/apps/web/src/R6Settings.tsx
2026-11-03 18:38:24 +08:00
kristinheandCursor 2155c4c773 feat(memhub): add safe model center
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 18:36:08 +08:00
kristinheandCursor 18e2fca34d fix(memhub): harden mode startup recovery and search retry
Close review follow-ups for graph backfill gates, onboarding resume,
settings refresh races, modal focus trapping, and raw search retry.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 18:01:18 +08:00
kristinheandCursor 88feb0adca feat(memhub): add memory mode app lifecycle
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 17:22:09 +08:00
feitianxiang d2e9cdaf06 Merge pull request #286 from Tencent-Hunyuan/chore/memhub-build-38
chore(release): MemHub 1.0.6, build 38
2026-11-02 21:47:36 +08:00
alvinfeiandClaude Opus 5.5 8277aaee51 chore(release): bump MemHub build number to 38
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 21:40:56 +08:00
feitianxiang 65550621e0 Merge pull request #285 from Tencent-Hunyuan/release/memhub-1.0.6-hy-2.0.13
chore(memhub): hy-memory 2.0.13; 1.0.6 changelog
2026-11-02 21:29:36 +08:00
alvinfeiandClaude Opus 5.5 1a67427bf3 Merge github/main (hy-memory 2.0.13 release) into release/memhub-1.0.6-hy-2.0.13
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 21:22:42 +08:00
feitianxiang 58f1477680 Merge pull request #282 from Tencent-Hunyuan/release/hy-memory-2.0.13
release: hy-memory 2.0.13
2026-11-02 21:20:29 +08:00
alvinfeiandClaude Opus 5.5 76c459e39a fix(memhub): a failed SDK write keeps the SDK's cause as its error code
hy-memory 2.0.13 reports a model failure as success: false with
agent_error_code / reconcile_error_code. The job's error_code now carries
that cause (hy_sdk_reconcile_empty_response, hy_sdk_llm_error, ...) instead
of a bare hy_sdk_write_failed; only upper-snake codes are kept, never a
message. Regression tests: a reconcile provider failure (401/503/timeout)
and a coding-path extractor failure wait for the provider and complete after
recovery; an empty or unparseable 200 reply is retried 3 times, then failed
with its cause.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 21:03:22 +08:00
alvinfeiandClaude Opus 5.5 b864e0bda6 chore(memhub): pin hy-memory 2.0.13; MemHub 1.0.6 changelog
Pin the Core dependency, lock, HY_MEMORY_VERSION, SBOM, notices, docs and
version guards to hy-memory 2.0.13. Move the MemHub entries from Unreleased
into the 1.0.6 section (build 37, on hy-memory 2.0.13) and update the v1.0.6
news lines.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 21:03:22 +08:00
alvinfeiandClaude Opus 5.5 6c235ef093 release: hy-memory 2.0.13
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 17:30:34 +08:00
feitianxiang 2483412475 Merge pull request #281 from Tencent-Hunyuan/fix/sdk-write-failures-not-silent
fix(sdk): a model failure during a write reports failure and stores nothing
2026-11-02 17:29:10 +08:00
feitianxiang f9ee983790 Merge pull request #278 from Tencent-Hunyuan/fix/queue-wal-and-recall-pools
fix(memhub): memory writes retry themselves, a broken model provider pauses the queue, WAL stays bounded, recall no longer starves
2026-11-02 17:28:56 +08:00
alvinfeiandClaude Opus 5.5 4746681efe fix(sdk): coding reconcile model failures fail the write, nothing half-applied
Experience reconcile no longer falls back to ADD-all, and fact reconcile no
longer inserts without superseding, when the model call fails, returns empty
content, or returns a reply that is not a JSON object with an ops list. The
write returns success=false, agent_error_code=RECONCILE_FAILED and the
cause in reconcile_error_code (LLM_UNAVAILABLE / EMPTY_RESPONSE /
JSON_PARSE_FAILED), the same as chat.

Each reconciler is split into plan() (retrieve + model decision, no store)
and apply() (store). CodingWriter plans both layers before applying either,
so a decision failure in either layer stores no experience or fact and
supersedes no older fact. reconcile() still runs plan + apply.

An experience reply of {"ops": []} is a valid "no changes" decision and
stores nothing. A non-empty ops list with no recognizable op is a parse
failure. A fact reply of {"ops": []} inserts without superseding.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 17:01:47 +08:00
alvinfeiandClaude Opus 5.5 76098cf060 fix(sdk): a model failure during a write reports failure and stores nothing
Reconcile (add / add_extracted): when the reconcile model call fails,
returns an empty 200 reply or an unparseable reply, or reconcile raises
before anything is stored, the write no longer falls back to adding every
extracted memory as-is with success=true. The ADD-all fallback and its
degraded flags are removed. The write returns success=false,
agent_error_code=RECONCILE_FAILED and the cause in reconcile_error_code
(LLM_UNAVAILABLE / EMPTY_RESPONSE / JSON_PARSE_FAILED /
RECONCILE_EXCEPTION). Nothing new is stored, the episode counter does not
advance, and add rolls back its L1 raw (retry_safe=true), so a re-run with
a new request_id is clean. Only a literal [] still means "no changes".
RECONCILE_ENABLED=false keeps writing directly.

Working / coding path: an extraction model failure (LLM_ERROR,
EMPTY_RESPONSE, JSON_PARSE_FAILED) and an experience or fact embedding
failure (EMBEDDING_FAILED) now return success=false instead of success
with 0 ops or a node without a vector. Embeddings are computed before the
SQLite row is written and before an older fact is superseded. A failed
write does not append its trace or record chain rounds. A model that
answers with nothing to remember is still a success.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 16:51:45 +08:00
203 changed files with 23778 additions and 3030 deletions

No files matched your search

+114 -54
View File
@@ -4,56 +4,71 @@ Hy Memory and MemHub are versioned and released independently.
## Unreleased
- MemHub multi-user hardening (follow-up to #250):
- Read responses name the user they were read for: `user_id` (the one
asked for, `local` by default) is now on `GET /api/v2/memory-library`,
`/memory-library/layers`, `/user-profile`, `/home-summary`,
`/memories/graph`, `/raw`, and `POST /api/v2/context/search`,
`/api/v1/recall`, `/api/v2/memories/ask`. `ask/stream` carries it on its
first (`recall`) event. The Host Bridge context validator accepts the new
field and still accepts a Core without it.
- `DELETE /api/v2/memories/{id}` answers 404 `memory_not_found` for another
user's memory and for an id that does not exist (the same response, so
existence is still not revealed), and also when the SDK finds nothing to
delete; 204 only when the caller's own memory was deleted. The App treats
that 404 as already gone (a double-click delete no longer shows an
error); `memhub memories delete` prints "未找到记忆 <id>(可能已删除)",
exits 0, and its JSON is `state: "committed"` with `already_gone: true`.
- Hermes never reads a `profiles/` directory at any depth (scan, detect and
live sync), so a root granted above `~/.hermes` cannot pick up another
user's profile. Connecting refuses a root that is the `profiles`
container itself (422 `hermes_root_is_profiles_container`) and a root
equal to, containing or inside another user's active Hermes grant (409
`hermes_root_overlaps_other_user`, symlinks resolved); `local` on
`~/.hermes` with another user on `~/.hermes/profiles/<name>` stays
allowed. The CLI explains both codes.
- `memhub sources connect --user-id` with an invalid id now says the user
id need not equal the Hermes profile name (e.g. `--user-id wife-work
--root ~/.hermes/profiles/wife:work`).
- 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's DeepSeek Harness app page can grant recall again (#230). The
"一键插入应用" switch used to be greyed out with `unsupported_host`, and a
profile installed from the CLI stayed `install-only` with no way to approve
it. The one switch now covers every detected profile
(`deepseek-harness:<profile>`): on installs each profile's MCP patch and
grants recall, off removes the patch and revokes the grant. The page lists
each profile with its state, and names any profile that failed while
keeping the others. While the switch is on, a profile created later is
installed and granted automatically, without restarting MemHub; the Runtime
also no longer fails every host read when it meets a profile created after
it started. The harness docs no longer describe an unmounted sources page.
## Hy Memory 2.0.14 — 2026-11-04
- MemHub's `deepseek-harness` source imports session files whose names carry the
format generation: `session.v3.jsonl.zstd` and `session.v4.jsonl.zstd` are now
discovered alongside the original `session.jsonl[.zstd]`. Discovery accepts
any `session[.vN].jsonl[.zstd]` name and the header's `version` decides
whether the session is parsed (v0/v3/v4) or refused as `unsupported_schema`.
A refused file is reported and keeps no cursor, so the release that learns
its format reads it from the top.
v4 tool results, which are written as plain `text` blocks rather than
`tool-result` blocks, are read through the same clipping and credential
scrubbing path. Previously every versioned session was invisible: the import
job completed with `discovered 0` while the files sat on disk.
- `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
- A model failure during a write now reports failure and stores
nothing. **Behaviour change for direct SDK users:** writes that used to
return `success: true` in a degraded state now return `success: false`;
retry them with a new `request_id` once the model is back.
- Reconcile (pro / ultra `add` and `add_extracted`): when the reconcile model
call fails, returns HTTP 200 with empty content, or returns a reply that
cannot be parsed, or reconcile raises before anything is stored, the write
used to fall back to adding every extracted memory as-is (no dedup or
evolution) and report success. That fallback is removed, with its
`degraded` / `degraded_reason` result fields. The write now returns
`success: false`, `error_code: 502`,
`agent_error_code: "RECONCILE_FAILED"`, and the cause in the new
`reconcile_error_code` (`LLM_UNAVAILABLE`, `EMPTY_RESPONSE`,
`JSON_PARSE_FAILED`, `RECONCILE_EXCEPTION`). None of the new memories are
stored, the episode counter does not advance, and `add` rolls back its
raw turn the same way as an extraction failure
(`l1_rollback.retry_safe: true`). An empty 200 reply was previously
counted as "no changes" and reported success with 0 memories; only a
literal `[]` reply still means that. `RECONCILE_ENABLED=false` is a
configuration choice and still writes directly.
- Working / coding path: an extraction model failure used to return
success with 0 operations. It now returns `success: false` with
`agent_error_code` `LLM_ERROR`, `EMPTY_RESPONSE` or `JSON_PARSE_FAILED`.
A model that answers with nothing worth remembering is still a success.
An embedding failure used to store the experience or fact without a
vector, where search never finds it, and report success; it now returns
`EMBEDDING_FAILED` and the node is not stored. A fact embedding is checked
before any older fact is superseded. A failed write no longer appends its
turns to the recent trace and records no `chain_rounds`.
With `MEMORY_CODING_VERBATIM_FALLBACK_ENABLED` turned on, extraction
failures keep the verbatim fallback.
- Working / coding reconcile: when the experience reconcile or the fact
reconcile model call fails, returns empty content, or returns a reply that
is not a JSON object with an `ops` list, the write returns
`success: false`, `agent_error_code: "RECONCILE_FAILED"` and the cause in
`reconcile_error_code` (`LLM_UNAVAILABLE`, `EMPTY_RESPONSE`,
`JSON_PARSE_FAILED`), the same as chat. Before, experience reconcile fell
back to adding every draft, and fact reconcile inserted the new fact
without superseding older ones. Both model decisions now run before
anything is stored, so a failure in either leaves no experience or fact
from the write and supersedes no older fact. An experience reply of
`{"ops": []}` is a valid "no changes" decision: the write succeeds and no
experience is stored. A non-empty `ops` list with no recognizable op is a
parse failure. A fact reply of `{"ops": []}` inserts the new fact without
superseding anything. Fact reconcile still makes no model call when there
are no candidates.
## Hy Memory 2.0.12 — 2026-11-02
@@ -77,14 +92,17 @@ Hy Memory and MemHub are versioned and released independently.
## MemHub 1.0.6 — 2026-11-02
Build 37, on `hy-memory==2.0.11`. Linux x64/arm64 Runtime and macOS arm64
Build 40, on `hy-memory==2.0.13`. Linux x64/arm64 Runtime and macOS arm64
App and Runtime.
- Interfaces in 11 languages: English, Simplified and Traditional Chinese,
Japanese, Spanish, German, Portuguese, French, Korean, Russian and
Indonesian. Choosing any language other than Simplified Chinese also sets
the SDK prompt language; Simplified Chinese keeps the automatic zh/en
detection.
Indonesian. Every interface language, Simplified Chinese included, sets the
SDK prompt language (#270). **User-visible:** zh-Hans users now get Chinese
prompts and Chinese memory text for every conversation, English ones
included; the App sends the language it displays, also when it follows the
system. Only an unset language (CLI/API installs, `memhub settings language
system`) keeps the SDK's zh/en detection.
- Memory modes: lite (no LLM, keeps raw conversations), pro (LLM extraction)
and ultra (pro plus the knowledge graph and a nightly evolve run). Set with
`GET`/`PUT /api/v1/settings/memory-mode` or `memhub settings mode`. Raw
@@ -97,10 +115,52 @@ App and Runtime.
embedding service is unavailable, search returns `embedding_unavailable`.
- Multi-user Runtime: memory APIs and the CLI take `user_id` (default
`local`); records are isolated per user; Hermes profiles can be connected as
separate users. The App keeps using `local`.
separate users. The App keeps using `local`. Hardening (#280): read
responses echo `user_id`; deleting another user's memory, or one that does
not exist, is 404 `memory_not_found` (the App and `memhub memories delete`
treat it as already gone); Hermes never reads a `profiles/` directory, and
a root that is the `profiles` container or overlaps another user's grant is
refused (`hermes_root_is_profiles_container`,
`hermes_root_overlaps_other_user`).
- Configurable SDK features through the CLI and API (`memhub settings
features`): graph rerank, episode recall, session episodes, episode
summaries, intention and few-shot. Defaults are unchanged.
summaries, intention and few-shot, and the intention dedup switch with its
threshold (`intention_dedup`, `intention_dedup_threshold`, #277). Defaults
are unchanged.
- Memory writes retry themselves (#278), each time under a new SDK request
id after checking whether the previous one landed; interrupted writes
re-run automatically. A model provider failure (401/403, 404, 408, 429,
5xx, timeout, unreachable) keeps the job pending in place without spending
a retry, and a per-profile circuit breaker paces the queue (5 s doubling to
180 s, one probe job at a time). Any other failure is retried after 30, 60
and 120 s, then `failed`. With `hy-memory==2.0.13` a model failure is no
longer a silent success, and a failed job's `error_code` names the SDK's
cause (`hy_sdk_reconcile_empty_response`, `hy_sdk_llm_error`, ...) rather
than `hy_sdk_write_failed`. **User-visible:** the manual 重新整理 button,
the "整理结果不确定" display and
`POST /api/v1/memory-processing/retry-unknown` are gone; a banner
("模型配置有问题……记忆暂停处理,已有 N 条在排队") with a button to the model
settings shows while a model problem pauses processing, and clears on
recovery.
- Queue, WAL and recall (#278): the WAL stays bounded (64 MiB journal limit,
idle `TRUNCATE` checkpoints); a disabled or revoked source's jobs are
`suspended` and resume when it is turned back on; SDK writes and recall
reads use separate thread pools, and recall has a total budget (504
`recall_timeout`, `broker_timeout` in clients) instead of starving behind
stuck writes.
- One Runtime per data dir (#273). App quit asks the service manager at quit
time: an enabled background service is never stopped, a disabled one has
every Runtime of the data dir stopped (only after an authenticated identity
check), and an unknown state stops nothing. At start the App adopts a
Runtime of the same build, replaces an older one, and silently adopts a
newer one. The tray quit label follows the current state.
- DeepSeek Harness: the app page's one switch installs and grants recall for
every detected profile, and keeps new profiles granted without a restart
(#230); versioned session files (`session.v3.jsonl.zstd`,
`session.v4.jsonl.zstd`) are imported, and v4 tool output goes through the
same clipping and scrubbing (#175).
- The packaged Runtime accepts only a full 40-character commit, lowercased; a
malformed `ONE_MEMORY_COMMIT` fails the build (#235, #236).
- Search and recall pass through the SDK's search parameters with server-side
score floors; the memory library filters by layer; `/api/v2/raw` returns
data again; v2 source and import routes; profile version history.
+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, and more reliable upgrades, on **`hy-memory==2.0.11`**. 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.13`**. 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.11`**。详见发布说明。
* *<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-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)**!
+1 -1
View File
@@ -107,5 +107,5 @@ L1_RAW 保存的内容使用上述渲染格式。历史回读时:
```bash
python -m pytest -q \
tests/test_multi_speaker_format.py \
tests/test_reconcile_fallback.py
tests/test_reconcile_failure.py
```
+17 -1
View File
@@ -150,7 +150,23 @@ working/coding 写入返回双层结果:
}
```
`scene="coding"` 还会返回 `chain_rounds`。出于历史兼容,该字段当前统计本次识别并处理
抽取 LLM 调用失败、返回空 content 或回复解析不出 JSON,以及经验 / 事实向量化失败时,
写入返回 `success=False`、`error_code=502`,`agent_error_code` 依次为 `LLM_ERROR`、
`EMPTY_RESPONSE`、`JSON_PARSE_FAILED`、`EMBEDDING_FAILED`,`error_message` 以
`[CODE]` 开头;没有向量的经验 / 事实不会落库,失败的写入也不追加对话 trace,换一个
`request_id` 重跑即可。模型正常作答但没有值得记的内容仍是 `success=True`、0 条。
显式开启 `MEMORY_CODING_VERBATIM_FALLBACK_ENABLED` 时抽取失败沿用原文兜底。
经验层 / 事实层 reconcile 的模型决策同样不再兜底:LLM 调用失败、返回空 content 或回复
不是带 `ops` 列表的 JSON 对象时,写入返回 `success=False`、
`agent_error_code="RECONCILE_FAILED"`,原因在 `reconcile_error_code`(`LLM_UNAVAILABLE` /
`EMPTY_RESPONSE` / `JSON_PARSE_FAILED`,与 chat 链路一致)。两层的模型决策都在落库之前
完成,任何一层失败时本次一条经验 / 事实都不存,旧事实也不会被覆盖。经验层回复
`{"ops": []}` 表示「无需任何操作」,写入成功、经验层不存;`ops` 非空却没有一条可识别的
op 视为解析失败。事实层回复 `{"ops": []}` 表示都不覆盖、照常插入新事实;没有候选时不
调用 LLM。
`scene="coding"` 还会返回 `chain_rounds`(写入失败时不记录演进链,也不返回该字段)。出于历史兼容,该字段当前统计本次识别并处理
的 diff hunk 数,而不保证等于最终合并后的唯一演进 round 数。`0` 表示没有识别到可记录
的代码修改,不代表双层 memory 写入失败。
+8
View File
@@ -128,6 +128,14 @@ result = client.add(
双层 reconcile 操作、`experience_ids`、`fact_ids` 等字段,完整结构见
[scene_coding_usage.md](scene_coding_usage.md#4-返回结构)。
写入失败时 `success=False`,`error_code` 为 HTTP 风格的整数,`agent_error_code` 给出稳定
失败码。pro / ultra 的 reconcile(新事实与已有记忆判重、合并)模型调用失败、返回空
content 或回复解析不出时,`add` 与 `add_extracted` 都报 `agent_error_code="RECONCILE_FAILED"`,
具体原因在 `reconcile_error_code`(`LLM_UNAVAILABLE` / `EMPTY_RESPONSE` /
`JSON_PARSE_FAILED` / `RECONCILE_EXCEPTION`)。此时本次一条新记忆都不落库,`add` 已写入
的原始对话按抽取失败同样回滚(`l1_rollback.retry_safe=True`);模型恢复后换一个
`request_id` 重跑即可。`RECONCILE_ENABLED=false` 时不调用 reconcile,直接写入。
`mode="lite"` 不执行 LLM 抽取,写入内容直接以 `L1_RAW` 形式保存。lite 的 Search 是
严格的 raw-only 向量检索:无论调用方传入什么 `layers`、`scene` 或 `search_mode`,都只
召回 `L1_RAW`,不会进入 working/coding、Schema 或 Intention 链路,也不会调用场景判断
+29 -83
View File
@@ -136,10 +136,10 @@ class ReconcileResult:
ops: List[ReconcileOp] = field(default_factory=list)
success: bool = True
error: Optional[str] = None
# True = reconcile 失败后走了「全部 ADD」兜底(见 _fallback_add_ops)。
# 结果仍然可用(success=True),但没有做过去重/演化判定,可能引入重复。
degraded: bool = False
degraded_reason: Optional[str] = None
# success=False 时的稳定失败码:LLM_UNAVAILABLE / EMPTY_RESPONSE /
# JSON_PARSE_FAILED / RECONCILE_EXCEPTION。失败时 ops 恒为空:writer 什么都
# 不落库,整次写入报失败,调用方换个 request_id 重跑即可(reconcile 会判重)。
error_code: Optional[str] = None
# reconcile 链路 LLM token 消耗(search-query + reconcile 两次 LLM 调用之和)
prompt_tokens: int = 0
completion_tokens: int = 0
@@ -151,7 +151,7 @@ class ReconcileResult:
# 用 content 作 key 而不是 node_id:新记忆此刻还没入库、没有 id。
candidate_scores: Dict[str, List[Tuple[str, float]]] = field(default_factory=dict)
# reconcile 候选检索已经为每条新 memory 算过 embedding。把原始文本对应向量交回
# writer,ADD-all / degraded ADD 可直接落库,避免同一事实再次请求 embedding。
# writer,原文不变的 ADD 可直接落库,避免同一事实再次请求 embedding。
# 仅按完全相同 content 复用;LLM 改写后的 UPDATE/SUPERSEDE 会重新计算,防止错配。
new_memory_embeddings: Dict[str, List[float]] = field(default_factory=dict)
@@ -394,49 +394,6 @@ class MemoryReconciler:
return [], None
@staticmethod
def _fallback_add_ops(
new_memories: List[str],
new_memories_with_meta: Optional[List[Dict]] = None,
) -> List[ReconcileOp]:
"""reconcile 彻底失败时的兜底:把抽取结果**原样全部 ADD**。
为什么必须有这个兜底:reconcile 只是决定「新事实和旧记忆怎么合」。它挂了(网关 500、
JSON 解析不出)不代表抽取结果没价值 —— 此前的行为是把一整批完好的事实**全部丢弃**,
线上实测因此丢掉过整块内容(含评测题的正确答案)。宁可留重复也不要丢内容:
重复还能被后续 dedup / reconcile 收拾,丢了就永远找不回来。
"""
metas = new_memories_with_meta or []
by_content = {
(m.get("content") or ""): m for m in metas if isinstance(m, dict)
}
ops: List[ReconcileOp] = []
for text in new_memories:
if not text:
continue
meta = by_content.get(text) or {}
ops.append(ReconcileOp(
op="ADD",
content=text,
layer=meta.get("layer"),
tags=meta.get("tags") or [],
owner=meta.get("owner"),
topic_paths=meta.get("topic_paths") or [],
event_facet=meta.get("event_facet") or {},
topic_assignments=meta.get("topic_assignments") or [],
topic_paths_provided=bool(
meta.get("topic_paths_provided", "topic_paths" in meta)
),
event_facet_provided=bool(
meta.get("event_facet_provided", "event_facet" in meta)
),
topic_assignments_provided=bool(meta.get(
"topic_assignments_provided", "topic_assignments" in meta,
)),
reason="reconcile unavailable → stored as-is (degraded)",
))
return ops
async def reconcile(
self,
new_memories: List[str],
@@ -1088,8 +1045,8 @@ class MemoryReconciler:
)
except Exception as e:
# LLM 彻底不可用(provider 已按 max_retries 重试过,网关 500 /
# 连接错误等)。**不能让它冒泡** —— 冒泡会让整批抽取结果连带丢弃。
# 交给下面的 ADD-all 兜底。
# 连接错误等)。不冒泡:下面统一记 RECONCILE 失败日志,再返回
# success=False —— writer 不落任何新记忆,整次写入报失败。
_llm_error = f"{type(e).__name__}: {e}"
logger.warning(f"[reconciler] RECONCILE LLM unavailable: {_llm_error}")
break
@@ -1097,7 +1054,7 @@ class MemoryReconciler:
_tok_pt += response.prompt_tokens
_tok_ct += response.completion_tokens
_tok_total += response.tokens_used
raw_response = response.content
raw_response = response.content or ""
ops = self._parse_ops(raw_response)
ops = enforce_time_order(ops, current_time, candidate_map)
ops = self._inherit_exact_input_structure(
@@ -1106,7 +1063,9 @@ class MemoryReconciler:
if _structure_metadata_on:
ops = self._resolve_structure_references(ops, candidate_map)
_is_empty_array = self._strip_code_fence(raw_response) in ("[]", "")
# 只有字面量 "[]" 才是「模型判定无需任何操作」。HTTP 200 但 content
# 为空不是决策,是失败:按 EMPTY_RESPONSE 处理,不能当成 0 条成功。
_is_empty_array = self._strip_code_fence(raw_response) == "[]"
if ops or _is_empty_array:
_parse_ok = True
break
@@ -1136,7 +1095,7 @@ class MemoryReconciler:
error_msg = (
f"RECONCILE LLM returned empty response after {_n_attempts} attempts"
)
error_code = "EMPTY_RESPONSE"
error_code = "EMPTY_RESPONSE"
logger.warning(f"[reconciler] {error_msg}")
if cache and request_id:
@@ -1153,14 +1112,13 @@ class MemoryReconciler:
{
"error": error_code,
"message": error_msg,
"fallback": "ADD_ALL",
"fallback_count": len(new_memories or []),
"stored": 0,
},
ensure_ascii=False,
),
elapsed_ms=recon_elapsed,
# LLM 彻底不可用时 response 是 None —— 这行本身会抛
# AttributeError,而它正好在兜底路径上,会把兜底也一起废掉
# LLM 彻底不可用时 response 是 None —— 直接取属性会抛
# AttributeError,把失败日志本身也废掉
prompt_tokens=getattr(response, "prompt_tokens", 0) or 0,
completion_tokens=getattr(response, "completion_tokens", 0) or 0,
total_tokens=getattr(response, "tokens_used", 0) or 0,
@@ -1170,22 +1128,16 @@ class MemoryReconciler:
f"[reconciler] store failed RECONCILE log failed: {e}"
)
# 兜底:JSON 解析不出来不等于抽取结果没价值 —— 全部 ADD,别丢内容。
_fb = self._fallback_add_ops(new_memories, new_memories_with_meta)
logger.warning(
f"[reconciler] falling back to ADD-all: {len(_fb)} memories "
f"({error_code})"
)
# 不做 ADD-all 兜底:没经过判重 / 演化的整批 ADD 会在重跑时产生重复,
# 而且调用方看到 success 就不会重试。这里报失败、不落库,由调用方重跑。
return ReconcileResult(
ops=_fb,
success=bool(_fb),
error=None if _fb else error_msg,
degraded=bool(_fb),
degraded_reason=error_code,
ops=[],
success=False,
error=error_msg,
error_code=error_code,
prompt_tokens=_tok_pt,
completion_tokens=_tok_ct,
total_tokens=_tok_total,
new_memory_embeddings=new_memory_embeddings,
)
# 写 RECONCILE pipeline log
@@ -1250,20 +1202,14 @@ class MemoryReconciler:
except Exception as e:
logger.error(f"[reconciler] failed: {e}", exc_info=True)
# 兜底同样适用于「reconcile 链路任意环节炸了」(候选检索、embed、
# prompt 组装…)。此前这里返回 ops=[] → writer 直接丢弃整批抽取结果。
_fb = self._fallback_add_ops(new_memories, new_memories_with_meta)
if _fb:
logger.warning(
f"[reconciler] falling back to ADD-all after unexpected error: "
f"{len(_fb)} memories"
)
return ReconcileResult(
ops=_fb, success=True,
degraded=True, degraded_reason=f"EXCEPTION:{type(e).__name__}",
new_memory_embeddings=dict(new_memory_embeddings or {}),
)
return ReconcileResult(ops=[], success=False, error=str(e))
# reconcile 链路任意环节炸了(候选检索、embed、prompt 组装…):此时还
# 没有任何新记忆落库,报失败让整次写入可以干净地重跑。
return ReconcileResult(
ops=[],
success=False,
error=f"{type(e).__name__}: {e}",
error_code="RECONCILE_EXCEPTION",
)
@staticmethod
def _inherit_exact_input_structure(
+28 -6
View File
@@ -134,6 +134,7 @@ _WRITE_DIAGNOSTIC_KEYS = (
"agent_status",
"agent_error_code",
"agent_error",
"reconcile_error_code",
"l1_rollback",
"l1_rollback_failed",
"partial_agent_persist",
@@ -1863,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,
@@ -1955,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/
@@ -1998,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,
@@ -3032,7 +3036,9 @@ class HyMemoryClient:
# 版本演进链层(scene="coding" 独有)。放在 writer.write 之后、返回
# 之前,因为它只依赖对话原文,与向量层写入结果无关。
# best-effort:这层挂不上不影响本次 add 的向量层产出。
if scene_variant == "coding":
# 向量层写入失败(success=False)时跳过:调用方会重跑整段,失败的
# 那次不该先把改动记进演进链。
if scene_variant == "coding" and resp.get("success"):
resp["chain_rounds"] = self._observe_chain(
chat_messages, user_id=uid,
workspace_id=workspace_id, branch=branch,
@@ -3384,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,
@@ -3399,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,
)
@@ -3417,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),
}
)
@@ -3861,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,
@@ -3890,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,
)
@@ -3905,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,
)
@@ -3935,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]:
@@ -4020,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,
@@ -4355,6 +4376,7 @@ class HyMemoryClient:
request_id=request_id,
t0=t0,
query_embedding=query_embedding,
statuses=statuses,
)
read_req = ReadRequest(
+2
View File
@@ -12,6 +12,7 @@ HY Memory - Coding Memory 模块
from .types import (
CodingMemory,
CodingWriteError,
CodingMemoryDraft,
ReconcileOp,
WorkingFact,
@@ -26,6 +27,7 @@ from .types import (
__all__ = [
"CodingMemory",
"CodingWriteError",
"CodingMemoryDraft",
"ReconcileOp",
"WorkingFact",
+57 -9
View File
@@ -33,7 +33,7 @@ from .preproc import (
prepare_extractor_messages,
)
from .judge import _store_coding_log
from .types import CodingMemoryDraft, WorkingFactDraft
from .types import CodingMemoryDraft, CodingWriteError, WorkingFactDraft
@@ -217,6 +217,10 @@ class CodingMemoryExtractor:
Returns:
(经验草稿, 事实草稿)。两层都可能为空。只有显式启用
verbatim_fallback_enabled 时,模型没产出 fact 才调用 _verbatim_fact。
Raises:
CodingWriteError: 未开启原文兜底时,LLM 调用失败(LLM_ERROR)、返回空
content(EMPTY_RESPONSE)或回复解析不出 JSON 对象(JSON_PARSE_FAILED)。
"""
if not messages:
return [], []
@@ -268,8 +272,9 @@ class CodingMemoryExtractor:
)
except Exception as e:
logger.warning(f"[coding-extract] LLM call failed: {e}")
# 经验层不兜底。事实层原文兜底也默认关闭,避免 assistant/tool 轨迹被
# 当作 statement 直接展示;仅在配置显式开启时保留旧行为。
# 经验层不兜底。事实层原文兜底默认关闭,避免 assistant/tool 轨迹被
# 当作 statement 直接展示;关闭时 LLM 失败抛 CodingWriteError(LLM_ERROR),
# 整次写入报失败,而不是「成功、0 条」。仅在配置显式开启时保留旧行为。
verbatim = (
self._verbatim_fact(messages)
if self.verbatim_fallback_enabled else None
@@ -296,9 +301,33 @@ class CodingMemoryExtractor:
),
elapsed_ms=round((time.perf_counter() - _t0) * 1000, 2),
)
if not self.verbatim_fallback_enabled:
raise CodingWriteError(
"LLM_ERROR",
f"coding extract LLM call failed: {type(e).__name__}: {e}",
) from e
return [], ([verbatim] if verbatim else [])
_elapsed = round((time.perf_counter() - _t0) * 1000, 2)
# 模型答了且没有可记的内容(如 {"experiences": [], "fact": null})是正常的空
# 抽取;200 但 content 为空、或回复解析不出 JSON 对象,是模型失败,不能当成
# 「成功、0 条」。原文兜底显式开启时沿用旧行为(交给下面的 verbatim 兜底)。
failure = self._output_failure(resp.content)
if failure is not None and not self.verbatim_fallback_enabled:
code, message = failure
await _store_coding_log(
self.cache, request_id=request_id, user_id=user_id, agent_id=agent_id,
session_id=session_id or "", step="CODING_EXTRACT", prompt=prompt,
response=resp.content or "",
parsed=json.dumps(
{"drafts": 0, "error": code, "message": message},
ensure_ascii=False,
),
elapsed_ms=_elapsed, prompt_tokens=resp.prompt_tokens,
completion_tokens=resp.completion_tokens, total_tokens=resp.tokens_used,
)
raise CodingWriteError(code, message)
raw_experiences, raw_facts = self._parse_output(resp.content)
# 经验层的 files 走规则抽取(整段轨迹碰过的文件);事实层的 files 由 LLM
@@ -478,12 +507,22 @@ class CodingMemoryExtractor:
return "\n".join(rows)
@classmethod
def _parse_output(
cls, text: str
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
"""宽松解析 LLM 输出 JSON,返回 (experiences, facts) 两个原始列表。"""
def _output_failure(cls, text: Optional[str]) -> Optional[Tuple[str, str]]:
"""回复本身是失败时返回 (error_code, message);正常(含空抽取)返回 None。"""
if not (text or "").strip():
return "EMPTY_RESPONSE", "coding extract LLM returned empty response"
if cls._load_output_object(text) is None:
return (
"JSON_PARSE_FAILED",
f"coding extract output is not a JSON object ({len(text)} chars)",
)
return None
@staticmethod
def _load_output_object(text: Optional[str]) -> Optional[Dict[str, Any]]:
"""宽松解析 LLM 输出,返回顶层 JSON 对象;解析不出返回 None。"""
if not text:
return [], []
return None
s = text.strip()
# 去 markdown fence
if s.startswith("```"):
@@ -509,8 +548,17 @@ class CodingMemoryExtractor:
obj = json.loads(s[start: end + 1])
except Exception:
obj = None
return obj if isinstance(obj, dict) else None
if not isinstance(obj, dict):
@classmethod
def _parse_output(
cls, text: str
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
"""宽松解析 LLM 输出 JSON,返回 (experiences, facts) 两个原始列表。"""
if not text:
return [], []
obj = cls._load_output_object(text)
if obj is None:
logger.warning(
f"[coding-extract] failed to parse output ({len(text or '')} chars)"
)
+127 -25
View File
@@ -18,6 +18,7 @@ import json
import logging
import os
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
@@ -34,6 +35,7 @@ from .types import (
MAX_FACT_CHAIN_ENTRIES,
CodingWriteError,
FactReconcileOp,
WorkingFact,
WorkingFactDraft,
@@ -259,6 +261,16 @@ def build_chain_summary_prompt(
return with_output_language(prompt, code, explicit=explicit is not None), code
@dataclass
class FactReconcilePlan:
"""plan() 的产物:覆盖判定已完成、尚未落库。apply() 才执行 store 操作。"""
drafts: List[WorkingFactDraft]
candidates: List[List[WorkingFact]] = field(default_factory=list)
supersedes: Dict[int, List[str]] = field(default_factory=dict)
context: Dict[str, Any] = field(default_factory=dict)
# ================================================================
# WorkingFactReconciler
# ================================================================
@@ -327,15 +339,63 @@ class WorkingFactReconciler:
language: Optional[str] = None,
) -> Tuple[List[WorkingFact], List[FactReconcileOp]]:
"""
执行 reconcile + 持久化。memory_at 是这段对话的时间,记到新事实上。
language 为显式 prompt 语言码(None → zh/en 自动检测),同时用于演变总结。
执行 reconcile + 持久化(plan() 后紧接 apply())。memory_at 是这段对话的
时间,记到新事实上。language 为显式 prompt 语言码(None → zh/en 自动检测),
同时用于演变总结。
Returns:
(真正落库的新事实, 执行过的 ops)。LLM 挂掉时退化为全 INSERT 不覆盖,
宁可多留一条过期事实,也不因为一次调用失败丢结论。
(真正落库的新事实, 执行过的 ops)。
Raises:
CodingWriteError(RECONCILE_FAILED):覆盖判定的 LLM 失败 / 空回复 /
解析不出。此时既不插入新事实,也不覆盖任何旧事实。
"""
plan = await self.plan(
drafts,
user_id=user_id,
agent_id=agent_id,
session_id=session_id,
workspace_id=workspace_id,
branch=branch,
source=source,
memory_at=memory_at,
language=language,
)
return await self.apply(plan)
async def plan(
self,
drafts: List[WorkingFactDraft],
*,
user_id: str,
agent_id: str = "default_agent",
session_id: Optional[str] = None,
workspace_id: Optional[str] = None,
branch: Optional[str] = None,
source: str = "default",
memory_at: Optional[datetime] = None,
language: Optional[str] = None,
) -> FactReconcilePlan:
"""检索 candidates + 覆盖判定,不落库。
没有任何候选时不调 LLM(全部 INSERT 是确定的结论,不是兜底)。调了 LLM 时,
有效回复 = JSON 对象且 "ops" 是列表(空列表 = 都不覆盖、只插入)。LLM 调用
失败(LLM_UNAVAILABLE)、200 但 content 为空(EMPTY_RESPONSE)、回复不是
上述形状(JSON_PARSE_FAILED)时抛 CodingWriteError(RECONCILE_FAILED)。
此前 LLM 失败会退化为全 INSERT 不覆盖,留下本该被接续的旧事实。
"""
context = dict(
user_id=user_id,
agent_id=agent_id,
session_id=session_id,
workspace_id=workspace_id,
branch=branch,
source=source,
memory_at=memory_at,
language=language,
)
if not drafts:
return [], []
return FactReconcilePlan(drafts=[], context=context)
candidates = await self._retrieve_candidates(
drafts,
@@ -349,27 +409,37 @@ class WorkingFactReconciler:
try:
supersedes = await self._llm_decide(drafts, candidates, language=language)
except CodingWriteError:
raise
except Exception as e:
logger.warning(
f"[fact-reconcile] LLM decide failed, insert without supersede: {e}"
)
supersedes = {}
logger.warning(f"[fact-reconcile] LLM decide failed: {e}")
raise CodingWriteError(
"RECONCILE_FAILED",
f"working fact reconcile LLM call failed: {type(e).__name__}: {e}",
cause="LLM_UNAVAILABLE",
) from e
return FactReconcilePlan(
drafts=list(drafts),
candidates=candidates,
supersedes=supersedes,
context=context,
)
async def apply(
self, plan: FactReconcilePlan,
) -> Tuple[List[WorkingFact], List[FactReconcileOp]]:
"""执行 plan() 的决策(覆盖旧事实 + 插入新事实)。"""
if not plan.drafts:
return [], []
facts, ops = await self._execute(
drafts,
candidates,
supersedes,
user_id=user_id,
agent_id=agent_id,
session_id=session_id,
workspace_id=workspace_id,
branch=branch,
source=source,
memory_at=memory_at,
language=language,
plan.drafts,
plan.candidates,
plan.supersedes,
**plan.context,
)
logger.info(
f"[fact-reconcile] drafts={len(drafts)} inserted={len(facts)} "
f"[fact-reconcile] drafts={len(plan.drafts)} inserted={len(facts)} "
f"superseded={sum(1 for o in ops if o.action == 'SUPERSEDE')}"
)
return facts, ops
@@ -473,6 +543,11 @@ class WorkingFactReconciler:
max_tokens=self.max_tokens,
audit_stage="coding_fact_reconcile",
)
failure = self._reply_failure(resp.content)
if failure is not None:
code, message = failure
logger.warning(f"[fact-reconcile] {code}: {message}")
raise CodingWriteError("RECONCILE_FAILED", message, cause=code)
return self._parse_ops(resp.content)
# ------------------------------------------------------------
@@ -599,11 +674,24 @@ class WorkingFactReconciler:
rows.append(f"for draft[{i}]:\n" + "\n".join(cand_rows))
return "\n\n".join(rows) if rows else "(none)"
@classmethod
def _reply_failure(cls, text: Optional[str]) -> Optional[Tuple[str, str]]:
"""回复本身是失败时返回 (cause, message);有效回复(含 {"ops": []})返回 None。"""
if not (text or "").strip():
return "EMPTY_RESPONSE", "working fact reconcile LLM returned empty response"
obj = cls._load_object(text)
if not isinstance(obj, dict) or not isinstance(obj.get("ops"), list):
return (
"JSON_PARSE_FAILED",
f"working fact reconcile output has no ops list ({len(text)} chars)",
)
return None
@staticmethod
def _parse_ops(text: str) -> Dict[int, List[str]]:
"""宽松解析 ops,返回 {draft_idx: [fact_id...]}。"""
def _load_object(text: Optional[str]) -> Optional[Dict[str, Any]]:
"""宽松解析出顶层 JSON 对象;解析不出返回 None。"""
if not text:
return {}
return None
s = text.strip()
# 去 markdown fence
if s.startswith("```"):
@@ -621,7 +709,14 @@ class WorkingFactReconciler:
obj = json.loads(s[start: end + 1])
except Exception:
obj = None
return obj if isinstance(obj, dict) else None
@classmethod
def _parse_ops(cls, text: str) -> Dict[int, List[str]]:
"""宽松解析 ops,返回 {draft_idx: [fact_id...]}。"""
if not text:
return {}
obj = cls._load_object(text)
if not isinstance(obj, dict):
logger.warning(
f"[fact-reconcile] failed to parse ops output ({len(text or '')} chars)"
@@ -689,6 +784,11 @@ class WorkingFactReconciler:
memory_at=memory_at,
)
# 先确认新事实能向量化,再去覆盖旧事实:否则 embedding 挂掉时旧事实
# 已被软删、新事实又落不了库,这条结论就凭空没了。失败抛
# CodingWriteError(EMBEDDING_FAILED),整次写入报失败。
new_embedding = await self.store.embed_fact(new_fact)
allowed = {c.fact_id for c in (candidates[idx] if idx < len(candidates) else [])}
targets = [
fid for fid in supersedes.get(idx, [])
@@ -769,7 +869,9 @@ class WorkingFactReconciler:
)
try:
await self.store.insert_fact(new_fact)
await self.store.insert_fact(new_fact, embedding=new_embedding)
except CodingWriteError:
raise
except Exception as e:
logger.warning(f"[fact-reconcile] INSERT failed: {e}")
continue
+130 -25
View File
@@ -7,6 +7,9 @@ Coding Memory Reconciler
(DELETE 由 LLM 在 draft 与 candidate 关系中观察到"明确否定/作废"时附带产出)
执行端:reconcile() 调用 store 完成实际 ADD / UPDATE / DELETE 持久化操作。
CodingWriter 分两步用:先 plan()(检索 + LLM 决策,不落库),等经验层与事实层的
模型决策都成功后再 apply()(落库)。LLM 失败 / 空回复 / 解析不出时 plan() 抛
CodingWriteError(RECONCILE_FAILED),本次写入什么都不存。
复用 LLMConfig.model(与 chat reconciler 共用)。
@@ -17,6 +20,7 @@ import json
import logging
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
from ..agent.llm_provider import LLMProvider
@@ -29,7 +33,7 @@ from ..prompts import (
)
from .judge import _store_coding_log
from .store import CodingMemoryStore
from .types import CodingMemory, CodingMemoryDraft, ReconcileOp
from .types import CodingMemory, CodingMemoryDraft, CodingWriteError, ReconcileOp
@@ -93,6 +97,15 @@ def build_experience_reconcile_prompt(
return with_output_language(prompt, code, explicit=explicit is not None), code
@dataclass
class ExperienceReconcilePlan:
"""plan() 的产物:模型已决策、尚未落库。apply() 才执行 store 操作。"""
drafts: List[CodingMemoryDraft]
candidates_per_draft: List[List[CodingMemory]] = field(default_factory=list)
ops: List[ReconcileOp] = field(default_factory=list)
# ================================================================
# CodingMemoryReconciler
# ================================================================
@@ -143,21 +156,52 @@ class CodingMemoryReconciler:
language: Optional[str] = None,
) -> List[ReconcileOp]:
"""
执行 reconcile + 持久化。返回执行的 ReconcileOp[](含成功执行的 ADD/UPDATE/DELETE/SKIP)。
执行 reconcile + 持久化(plan() 后紧接 apply())。返回执行的 ReconcileOp[]
(含成功执行的 ADD/UPDATE/DELETE/SKIP)。
执行错误(如 LLM 失败)会 fail-safe 回退到对每个 draft 直接 ADD。
LLM 失败、空回复或解析不出时抛 CodingWriteError(RECONCILE_FAILED),什么都
不落库——不再回退成「全部 ADD」。
"""
plan = await self.plan(
drafts,
request_id=request_id,
user_id=user_id,
agent_id=agent_id,
session_id=session_id,
language=language,
)
return await self.apply(plan)
cache 非空时落 CODING_RECONCILE pipeline log——在 LLM 决策后、执行入库前打点,
这样即使 store 入库崩溃,reconcile 决策仍已可见(crash 不丢)。
async def plan(
self,
drafts: List[CodingMemoryDraft],
*,
request_id: str = "",
user_id: str = "",
agent_id: str = "default_agent",
session_id: str = "",
language: Optional[str] = None,
) -> ExperienceReconcilePlan:
"""检索 candidates + LLM 决策,不落库。
有效回复 = 一个 JSON 对象,其 "ops" 是列表:
- {"ops": []}:模型判定「无需任何操作」(与 chat reconcile 的 [] 同义),
成功、本层什么都不存;
- 非空列表:至少要有一条可识别的 op(ADD/UPDATE/DELETE/SKIP),否则视为
解析失败。漏掉的 draft 仍按原规则补 ADD(那是对有效回复的校验兜底,
不是模型失败兜底)。
LLM 调用失败(LLM_UNAVAILABLE)、200 但 content 为空(EMPTY_RESPONSE)、
回复不是上述形状(JSON_PARSE_FAILED)时抛 CodingWriteError(RECONCILE_FAILED)。
cache 非空时落 CODING_RECONCILE pipeline log(成功、失败都落)。
"""
if not drafts:
return []
return ExperienceReconcilePlan(drafts=[])
# 从 draft 回填 trace 身份(writer 通常显式传,这里兜底)
if drafts:
user_id = user_id or drafts[0].user_id
agent_id = agent_id or drafts[0].agent_id
session_id = session_id or (drafts[0].session_id or "")
user_id = user_id or drafts[0].user_id
agent_id = agent_id or drafts[0].agent_id
session_id = session_id or (drafts[0].session_id or "")
# 1. 检索 candidates
candidates_per_draft: List[List[CodingMemory]] = await self._retrieve_candidates(drafts)
@@ -167,19 +211,20 @@ class CodingMemoryReconciler:
_decide_prompt = ""
_decide_raw = ""
_decide_resp = None
failure: Optional[Tuple[str, str]] = None
ops: List[ReconcileOp] = []
try:
ops, _decide_prompt, _decide_raw, _decide_resp = await self._llm_decide(
drafts, candidates_per_draft, language=language,
)
failure = self._reply_failure(_decide_raw)
except Exception as e:
logger.warning(f"[coding-reconcile] LLM decide failed, fallback to ADD-all: {e}")
ops = [ReconcileOp(action="ADD", draft_idx=i) for i in range(len(drafts))]
logger.warning(f"[coding-reconcile] LLM decide failed: {e}")
_decide_raw = f"[LLM error] {type(e).__name__}: {str(e)[:300]}"
if not ops:
# LLM 输出无 ops,但有 drafts → fallback ADD-all
logger.info("[coding-reconcile] LLM returned no ops, fallback to ADD-all")
ops = [ReconcileOp(action="ADD", draft_idx=i) for i in range(len(drafts))]
failure = (
"LLM_UNAVAILABLE",
f"coding reconcile LLM call failed: {type(e).__name__}: {e}",
)
_decide_elapsed = round((time.perf_counter() - _t0) * 1000, 2)
# CODING_RECONCILE:LLM 决策完成即打点(执行入库之前,crash 不丢)
@@ -187,17 +232,42 @@ class CodingMemoryReconciler:
self.cache, request_id=request_id, user_id=user_id, agent_id=agent_id,
session_id=session_id, step="CODING_RECONCILE",
prompt=_decide_prompt, response=_decide_raw,
parsed=json.dumps({"ops": [o.to_dict() for o in ops]}, ensure_ascii=False, default=str),
parsed=json.dumps(
{"error": failure[0], "message": failure[1], "stored": 0}
if failure else {"ops": [o.to_dict() for o in ops]},
ensure_ascii=False, default=str,
),
elapsed_ms=_decide_elapsed,
prompt_tokens=getattr(_decide_resp, "prompt_tokens", 0) if _decide_resp else 0,
completion_tokens=getattr(_decide_resp, "completion_tokens", 0) if _decide_resp else 0,
total_tokens=getattr(_decide_resp, "tokens_used", 0) if _decide_resp else 0,
)
if failure is not None:
code, message = failure
logger.warning(f"[coding-reconcile] {code}: {message}")
raise CodingWriteError("RECONCILE_FAILED", message, cause=code)
# 3. 校验 + 执行
executed = await self._execute(ops, drafts, candidates_per_draft)
return ExperienceReconcilePlan(
drafts=list(drafts),
candidates_per_draft=candidates_per_draft,
ops=ops,
)
async def apply(self, plan: ExperienceReconcilePlan) -> List[ReconcileOp]:
"""执行 plan() 的决策(store ADD / UPDATE / DELETE)。"""
if not plan.drafts:
return []
if not plan.ops:
# 有效的 {"ops": []}:模型判定无需任何操作,本层什么都不存。
logger.info(
f"[coding-reconcile] drafts={len(plan.drafts)} LLM decided no ops"
)
return []
executed = await self._execute(
plan.ops, plan.drafts, plan.candidates_per_draft,
)
logger.info(
f"[coding-reconcile] drafts={len(drafts)} ops={len(executed)} "
f"[coding-reconcile] drafts={len(plan.drafts)} ops={len(executed)} "
+ ", ".join(f"{a}={sum(1 for o in executed if o.action == a)}"
for a in ("ADD", "UPDATE", "DELETE", "SKIP"))
)
@@ -316,11 +386,30 @@ class CodingMemoryReconciler:
rows.append(f"for draft[{i}]:\n" + "\n".join(cand_rows))
return "\n\n".join(rows) if rows else "(none)"
@classmethod
def _reply_failure(cls, text: Optional[str]) -> Optional[Tuple[str, str]]:
"""回复本身是失败时返回 (cause, message);有效回复(含 {"ops": []})返回 None。"""
if not (text or "").strip():
return "EMPTY_RESPONSE", "coding reconcile LLM returned empty response"
obj = cls._load_object(text)
ops_raw = obj.get("ops") if isinstance(obj, dict) else None
if not isinstance(ops_raw, list):
return (
"JSON_PARSE_FAILED",
f"coding reconcile output has no ops list ({len(text)} chars)",
)
if ops_raw and not cls._parse_ops(text):
return (
"JSON_PARSE_FAILED",
f"coding reconcile output has no recognizable op ({len(text)} chars)",
)
return None
@staticmethod
def _parse_ops(text: str) -> List[ReconcileOp]:
"""宽松解析 ops 列表。"""
def _load_object(text: Optional[str]) -> Optional[Dict[str, Any]]:
"""宽松解析出顶层 JSON 对象;解析不出返回 None。"""
if not text:
return []
return None
s = text.strip()
# 去 markdown fence
if s.startswith("```"):
@@ -344,8 +433,15 @@ class CodingMemoryReconciler:
obj = json.loads(s[start: end + 1])
except Exception:
obj = None
return obj if isinstance(obj, dict) else None
if not obj or not isinstance(obj, dict):
@classmethod
def _parse_ops(cls, text: str) -> List[ReconcileOp]:
"""宽松解析 ops 列表。"""
if not text:
return []
obj = cls._load_object(text)
if not obj:
logger.warning(
f"[coding-reconcile] failed to parse ops output ({len(text or '')} chars)"
)
@@ -394,6 +490,9 @@ class CodingMemoryReconciler:
- UPDATE 需 draft_idx 合法 + target_experience_id 非空
- DELETE 需 target_experience_id 非空
- SKIP 不执行任何 store 操作
向量化失败(CodingWriteError(EMBEDDING_FAILED))不吞成「少执行一条」,
原样抛给 CodingWriter,整次写入报失败。
"""
executed: List[ReconcileOp] = []
# 已被 UPDATE 占用的 draft_idx → 防止同 draft 被重复 ADD/UPDATE
@@ -414,6 +513,8 @@ class CodingMemoryReconciler:
op.target_experience_id = memory.experience_id # 回写 id 供 caller 读
executed.append(op)
consumed.add(op.draft_idx)
except CodingWriteError:
raise
except Exception as e:
logger.warning(f"[coding-reconcile] ADD failed: {e}")
@@ -444,6 +545,8 @@ class CodingMemoryReconciler:
await self.store.update(memory)
executed.append(op)
consumed.add(op.draft_idx)
except CodingWriteError:
raise
except Exception as e:
logger.warning(f"[coding-reconcile] UPDATE failed: {e}")
@@ -487,6 +590,8 @@ class CodingMemoryReconciler:
target_experience_id=memory.experience_id,
reason="fallback ADD (no LLM op)",
))
except CodingWriteError:
raise
except Exception as e:
logger.warning(f"[coding-reconcile] fallback ADD failed: {e}")
+109 -26
View File
@@ -44,7 +44,7 @@ from ..data.vector_store import create_vector_store
from ..data.vector_store_base import VectorStoreBase
from ..models.memory import MemoryLayer, MemoryNode, MemoryStatus
from ..utils.async_executor import run_in_executor_guarded
from .types import CodingMemory, WorkingFact
from .types import CodingMemory, CodingWriteError, WorkingFact
logger = logging.getLogger(__name__)
@@ -599,10 +599,15 @@ class CodingMemoryStore:
# ------------------------------------------------------------
async def insert(self, memory: CodingMemory) -> None:
"""新增一条 coding memory(SQLite + VDB 全套)"""
"""新增一条 coding memory(SQLite + VDB 全套)。
先向量化再落 SQLite:向量化失败抛 CodingWriteError(EMBEDDING_FAILED),
什么都不写,不留一条没有向量、永远检索不到的经验。
"""
self._fill_timestamps(memory)
embeddings = await self._embed_vec_keys(memory)
await _run_in_pool(self._upsert_sql_sync, memory)
await self._upsert_vec_keys(memory)
await self._upsert_vec_keys(memory, embeddings=embeddings)
logger.info(
f"[coding-store] inserted experience_id={memory.experience_id} "
f"keys={1 + len(memory.search_keys)}"
@@ -627,6 +632,8 @@ class CodingMemoryStore:
):
raise ValueError("coding_memory_update_authority_mismatch")
prev_key_count = 1 + len(prev.search_keys)
# 先向量化:失败时旧经验(SQLite 与向量行)原样保留,不改一个字。
embeddings = await self._embed_vec_keys(memory)
memory.updated_at = datetime.utcnow()
if memory.created_at is None:
@@ -637,7 +644,7 @@ class CodingMemoryStore:
# 删旧 VDB
await self._delete_vec_keys_by_count(memory.experience_id, prev_key_count)
# 写新 VDB
await self._upsert_vec_keys(memory)
await self._upsert_vec_keys(memory, embeddings=embeddings)
logger.info(
f"[coding-store] updated experience_id={memory.experience_id} "
f"old_keys={prev_key_count} new_keys={1 + len(memory.search_keys)}"
@@ -1000,12 +1007,22 @@ class CodingMemoryStore:
# 事实层
# ------------------------------------------------------------
async def insert_fact(self, fact: WorkingFact) -> None:
"""新增一条事实(SQLite + 事实 collection 各一行)。"""
async def insert_fact(
self, fact: WorkingFact, embedding: Optional[List[float]] = None,
) -> None:
"""新增一条事实(SQLite + 事实 collection 各一行)。
embedding 可由调用方预先用 embed_fact() 算好(fact reconcile 要在覆盖旧
事实之前确认新事实能向量化);不传时这里算。向量化失败抛
CodingWriteError(EMBEDDING_FAILED),什么都不写。
"""
if fact.created_at is None:
fact.created_at = datetime.utcnow()
# 先向量化再落 SQLite,理由同 insert():不落没有向量的事实。
if embedding is None:
embedding = await self.embed_fact(fact)
await _run_in_pool(self._upsert_fact_sql_sync, fact)
await self._upsert_fact_vec(fact)
await self._upsert_fact_vec(fact, embedding=embedding)
logger.info(f"[coding-store] inserted fact_id={fact.fact_id}")
logger.debug(
f"[coding-store] inserted fact_id={fact.fact_id} "
@@ -1574,25 +1591,64 @@ class CodingMemoryStore:
# VDB 实现
# ------------------------------------------------------------
async def _upsert_vec_keys(self, memory: CodingMemory) -> None:
"""把 task + search_keys 写入 VDB(每行一个 MemoryNode)。"""
if self._vec is None:
return
@staticmethod
def _vec_key_texts(memory: CodingMemory) -> List[Tuple[str, str]]:
all_keys: List[Tuple[str, str]] = [("task", memory.task)]
for k in memory.search_keys:
if k:
all_keys.append(("aspect", k))
return all_keys
async def _embed_vec_keys(
self, memory: CodingMemory
) -> Optional[List[List[float]]]:
"""写入链路用:向量化 task + search_keys,失败抛 CodingWriteError。
无 VDB 时返回 None(不需要向量)。
"""
if self._vec is None:
return None
texts = [t for _, t in self._vec_key_texts(memory)]
try:
embeddings = await self.embed_service.embed_batch(texts)
except Exception as e:
logger.warning(f"[coding-store] embed_batch failed: {e}")
raise CodingWriteError(
"EMBEDDING_FAILED",
f"coding experience embedding failed: {type(e).__name__}: {e}",
) from e
if len(embeddings or []) != len(texts) or not all(embeddings):
raise CodingWriteError(
"EMBEDDING_FAILED",
"coding experience embedding returned no vector",
)
return embeddings
async def _upsert_vec_keys(
self,
memory: CodingMemory,
embeddings: Optional[List[List[float]]] = None,
) -> None:
"""把 task + search_keys 写入 VDB(每行一个 MemoryNode)。
embeddings 由写入链路预先算好(见 _embed_vec_keys);不传时现算,
失败只记 warning(edit_experience 的手动编辑路径沿用这一行为)。
"""
if self._vec is None:
return
all_keys = self._vec_key_texts(memory)
if not all_keys:
return
# batch embed
texts = [t for _, t in all_keys]
try:
embeddings = await self.embed_service.embed_batch(texts)
except Exception as e:
logger.warning(f"[coding-store] embed_batch failed: {e}")
return
if embeddings is None:
try:
embeddings = await self.embed_service.embed_batch(texts)
except Exception as e:
logger.warning(f"[coding-store] embed_batch failed: {e}")
return
nodes: List[MemoryNode] = []
for i, ((kind, text), emb) in enumerate(zip(all_keys, embeddings)):
@@ -1623,7 +1679,34 @@ class CodingMemoryStore:
except Exception as e:
logger.warning(f"[coding-store] vdb upsert_batch failed: {e}")
async def _upsert_fact_vec(self, fact: WorkingFact) -> None:
@staticmethod
def _fact_vector_text(fact: WorkingFact) -> str:
return f"{fact.subject}: {fact.statement}" if fact.subject else fact.statement
async def embed_fact(self, fact: WorkingFact) -> Optional[List[float]]:
"""写入链路用:向量化事实检索文本,失败抛 CodingWriteError。
无事实 VDB 或 statement 为空时返回 None(不需要向量)。
"""
if self._fact_vec is None or not fact.statement:
return None
try:
embedding = await self.embed_service.embed(self._fact_vector_text(fact))
except Exception as e:
logger.warning(f"[coding-store] fact embed failed: {e}")
raise CodingWriteError(
"EMBEDDING_FAILED",
f"working fact embedding failed: {type(e).__name__}: {e}",
) from e
if not embedding:
raise CodingWriteError(
"EMBEDDING_FAILED", "working fact embedding returned no vector",
)
return embedding
async def _upsert_fact_vec(
self, fact: WorkingFact, embedding: Optional[List[float]] = None,
) -> None:
"""事实入向量库。一条事实一行,node_id 就是 fact_id。
检索文本是 statement,前面缀上 subject(有的话)。subject 通常已经在
@@ -1634,14 +1717,14 @@ class CodingMemoryStore:
"""
if self._fact_vec is None or not fact.statement:
return
vector_text = (
f"{fact.subject}: {fact.statement}" if fact.subject else fact.statement
)
try:
embedding = await self.embed_service.embed(vector_text)
except Exception as e:
logger.warning(f"[coding-store] fact embed failed: {e}")
return
vector_text = self._fact_vector_text(fact)
if embedding is None:
# edit_fact 的手动编辑路径:现算,失败只记 warning。
try:
embedding = await self.embed_service.embed(vector_text)
except Exception as e:
logger.warning(f"[coding-store] fact embed failed: {e}")
return
node = MemoryNode(
node_id=fact.fact_id,
+19
View File
@@ -25,6 +25,25 @@ FactStatus = Literal["active", "superseded"]
MAX_FACT_CHAIN_ENTRIES = 8
class CodingWriteError(RuntimeError):
"""working / coding 写入链路里让整次写入失败的模型错误。
error_code 是稳定失败码,CodingWriter 原样透传为 agent_error_code:
- LLM_ERROR 抽取 LLM 调用失败
- EMPTY_RESPONSE 抽取 LLM 返回 200 但 content 为空
- JSON_PARSE_FAILED 抽取 LLM 的回复解析不出 JSON 对象
- EMBEDDING_FAILED 经验 / 事实向量化失败(不落没有向量的节点)
- RECONCILE_FAILED 经验 / 事实 reconcile 的模型决策失败;具体原因在 cause
(LLM_UNAVAILABLE / EMPTY_RESPONSE / JSON_PARSE_FAILED),
透传为 reconcile_error_code,与 chat 链路一致
"""
def __init__(self, error_code: str, message: str, cause: Optional[str] = None):
super().__init__(message)
self.error_code = error_code
self.cause = cause
def _epoch(value: Optional[datetime]) -> Optional[int]:
"""memory_at 对外是秒级 epoch,与 chat 一致。"""
return int(value.timestamp()) if isinstance(value, datetime) else None
+108 -43
View File
@@ -10,8 +10,9 @@ CodingWriter - 写入端协调器
├── recent history: 最近 K 个 user-turn(只作上下文)
├── new messages: 本次全部 turn(只做单条限长)
├── extractor.extract(...) ← 一次 LLM 产出双层草稿
├── reconciler.reconcile(经验草稿) ← ADD/UPDATE/DELETE/SKIP
├── fact_reconciler.reconcile(事实草稿) ← INSERT/SUPERSEDE
├── reconciler.plan(经验草稿) ← ADD/UPDATE/DELETE/SKIP 决策
├── fact_reconciler.plan(事实草稿) ← INSERT/SUPERSEDE 决策
├── reconciler.apply / fact_reconciler.apply ← 两层决策都成功后才落库
└── _crosslink(...) ← 同批全互链回填
"""
@@ -30,7 +31,7 @@ from .judge import classify_messages_is_coding
from .preproc import has_any_tool_message, prepare_extractor_messages
from .reconciler import CodingMemoryReconciler
from .store import CodingMemoryStore
from .types import FactReconcileOp, ReconcileOp, WorkingFact
from .types import CodingWriteError, FactReconcileOp, ReconcileOp, WorkingFact
logger = logging.getLogger(__name__)
@@ -104,6 +105,13 @@ class CodingWriter:
],
"fact_ids": ["..."], # 这趟真正落库的 fact_id
}
模型失败(抽取 LLM 调用失败 / 空回复 / 解析不出)或向量化失败时返回
success=False,agent_error_code 为 LLM_ERROR / EMPTY_RESPONSE /
JSON_PARSE_FAILED / EMBEDDING_FAILED,error_code=502。经验层 / 事实层
reconcile 的模型决策失败时 agent_error_code=RECONCILE_FAILED,原因在
reconcile_error_code(LLM_UNAVAILABLE / EMPTY_RESPONSE / JSON_PARSE_FAILED),
本次什么都不落库。模型答了但没有值得记的内容仍是 success=True、0 条。
"""
request_id = request_id or str(uuid.uuid4())
t0 = time.perf_counter()
@@ -143,6 +151,10 @@ class CodingWriter:
# 跟库里已有的什么关系是 reconcile 的事。把已有 task 摆到它面前,它会把
# 一个其实不同的任务往清单里某个措辞上靠,reconciler 随后判成 UPDATE 合并
# 掉,那个区别就没了——措辞对不上顶多多存一条,措辞被带偏是直接写错。
#
# 模型失败(LLM 调用失败 / 空回复 / 解析不出)抛 CodingWriteError:整次写入
# 报失败、什么都不存,连 trace 也不追加——调用方换个 request_id 重跑时,
# 历史窗口里不会多出这段本该是「新消息」的对话。
try:
drafts, fact_drafts = await self.extractor.extract(
messages,
@@ -156,26 +168,26 @@ class CodingWriter:
request_id=request_id,
language=language,
)
finally:
# 历史服务于下一次 write;即使本次没跨过价值门槛,也不能丢掉上下文。
try:
await self.store.append_trace(
current_messages,
request_id=request_id,
user_id=user_id,
agent_id=agent_id or "default_agent",
session_id=session_id,
workspace_id=workspace_id,
branch=branch,
source=source,
)
except Exception as e:
logger.warning(f"[coding-write] append trace failed: {e}")
except CodingWriteError as e:
return self._failure_response(request_id, t0, e)
trace_kwargs = dict(
request_id=request_id,
user_id=user_id,
agent_id=agent_id or "default_agent",
session_id=session_id,
workspace_id=workspace_id,
branch=branch,
source=source,
)
if not drafts and not fact_drafts:
# 模型答了、只是没有值得记的:正常的空抽取,仍算成功。历史服务于下一次
# write;即使本次没跨过价值门槛,也不能丢掉上下文。
logger.info(
f"[coding-write] no drafts produced (value bar); request_id={request_id}"
)
await self._append_trace(current_messages, **trace_kwargs)
return self._empty_response(request_id, t0)
# extractor 只负责内容;调用来源由 SDK 入口注入,不能交给 LLM 生成。
@@ -183,34 +195,51 @@ class CodingWriter:
draft.source = source
draft.memory_at = memory_at
# 3. 经验层 reconcile + 持久化
ops: List[ReconcileOp] = (
await self.reconciler.reconcile(
drafts,
request_id=request_id,
user_id=user_id,
agent_id=agent_id,
session_id=session_id or "",
language=language,
)
if drafts else []
)
# 4. 事实层 reconcile + 持久化(独立链路,不受经验层结果影响)
# 3. 两层 reconcile 的模型决策(只检索 + 调 LLM,不落库)
# 4. 两层决策都成功后才落库:经验层 apply → 事实层 apply
#
# 先把所有模型决策做完再写库,任何一层的决策失败(LLM 失败 / 空回复 /
# 解析不出 → CodingWriteError(RECONCILE_FAILED))都发生在落库之前,本次
# 写入一条经验 / 事实都没存、也没覆盖旧事实,换个 request_id 重跑即干净。
# 两层决策互不依赖(各自检索各自的候选),提前做不改变结果。
#
# 向量化失败抛 CodingWriteError(EMBEDDING_FAILED):不落没有向量的节点,
# 整次写入报失败。
facts: List[WorkingFact] = []
fact_ops: List[FactReconcileOp] = []
if fact_drafts:
facts, fact_ops = await self.fact_reconciler.reconcile(
fact_drafts,
user_id=user_id,
agent_id=agent_id,
session_id=session_id,
workspace_id=workspace_id,
branch=branch,
source=source,
memory_at=memory_at,
language=language,
ops: List[ReconcileOp] = []
try:
experience_plan = (
await self.reconciler.plan(
drafts,
request_id=request_id,
user_id=user_id,
agent_id=agent_id,
session_id=session_id or "",
language=language,
)
if drafts else None
)
fact_plan = (
await self.fact_reconciler.plan(
fact_drafts,
user_id=user_id,
agent_id=agent_id,
session_id=session_id,
workspace_id=workspace_id,
branch=branch,
source=source,
memory_at=memory_at,
language=language,
)
if fact_drafts else None
)
if experience_plan is not None:
ops = await self.reconciler.apply(experience_plan)
if fact_plan is not None:
facts, fact_ops = await self.fact_reconciler.apply(fact_plan)
except CodingWriteError as e:
return self._failure_response(request_id, t0, e)
experience_ids = [op.target_experience_id for op in ops
if op.action in ("ADD", "UPDATE") and op.target_experience_id]
@@ -218,6 +247,7 @@ class CodingWriter:
# 5. 同批全互链
await self._crosslink(experience_ids, facts, user_id=user_id)
await self._append_trace(current_messages, **trace_kwargs)
add_cnt = sum(1 for op in ops if op.action == "ADD")
update_cnt = sum(1 for op in ops if op.action == "UPDATE")
@@ -298,6 +328,41 @@ class CodingWriter:
f"[coding-write] crosslink fact {f.fact_id} failed: {e}"
)
async def _append_trace(self, current_messages: List[ChatMessage], **kwargs) -> None:
"""把本次对话追加进 recent trace(下一次 write 的历史上下文),best-effort。"""
try:
await self.store.append_trace(current_messages, **kwargs)
except Exception as e:
logger.warning(f"[coding-write] append trace failed: {e}")
def _failure_response(
self, request_id: str, t0: float, error: CodingWriteError,
) -> Dict[str, Any]:
"""模型 / 向量化失败:success=False,稳定失败码在 agent_error_code。
形状与 chat 链路的失败回执一致(error_code=502,error_message 带
「[CODE] 」前缀),调用方据 success 判定并重跑。
"""
code = error.error_code
cause = getattr(error, "cause", None)
message = f"{cause}: {error}" if cause else str(error)
logger.warning(
f"[coding-write] failed: code={code} request_id={request_id}: {message}"
)
resp = self._empty_response(request_id, t0)
resp.update({
"success": False,
"error_code": 502,
"error_message": f"[{code}] {message}",
"agent_status": "failed",
"agent_error_code": code,
"agent_error": message,
})
if cause:
# 与 chat 链路一致:reconcile 失败的具体原因
resp["reconcile_error_code"] = cause
return resp
def _empty_response(self, request_id: str, t0: float) -> Dict[str, Any]:
return {
"success": True,
+70 -30
View File
@@ -73,6 +73,19 @@ def _write_apply_verify_enabled() -> bool:
_RECONCILE_LOOP_USER_LOCKS = weakref.WeakKeyDictionary()
class ReconcileFailure(str):
"""reconcile 失败时 `_reconcile_and_store` 返回的 error:仍是错误信息字符串,
另带稳定的失败码 `code`(LLM_UNAVAILABLE / EMPTY_RESPONSE / JSON_PARSE_FAILED /
RECONCILE_EXCEPTION),供 writer 透传给调用方。"""
code: str = "RECONCILE_FAILED"
def __new__(cls, message: str, code: str = "RECONCILE_FAILED"):
obj = super().__new__(cls, message)
obj.code = code
return obj
def _reconcile_user_lock(user_id: str) -> asyncio.Lock:
loop = asyncio.get_running_loop()
locks = _RECONCILE_LOOP_USER_LOCKS.get(loop)
@@ -1262,15 +1275,6 @@ class MemoryWriter(WritePipeline):
recon_tokens 含 prompt/completion/total(reconcile 链路 LLM 消耗)
"""
stored_ids: List[str] = []
# Episode:本次写入归属的内部会话 id(跨 write 把"一件事"归到一起)。
# 只数 user 侧记忆(与 rolling summary 只数 pending_user_count 一致)——
# agent 侧记忆若参与计数会把 episode 提前撑到上限、切得过碎,白付摘要的 token。
episode_id = await self._resolve_episode_id(
request, _count_user_owned(new_memories_meta)
)
session_episode_id = self._session_episode_id(request)
if session_episode_id:
episode_id = session_episode_id
# 关系图用:收集本次落库的 (node, op),供 graph 投影建边。
# 收 op 是为了拿 op.related(LLM 标注的 typed 关系)。
graph_payload: List[Tuple[Any, Any]] = []
@@ -1297,7 +1301,32 @@ class MemoryWriter(WritePipeline):
)
if not recon_result.success:
return [], recon_result.error, {"add": 0, "supersede": 0, "update": 0, "total": 0}, {"prompt": 0, "completion": 0, "total": 0}
# reconcile 失败:本次一条新记忆都不落库(不做 ADD-all 兜底),由调用方重跑。
# episode 状态也还没推进,重跑不会把同一批记忆重复计数。
error = ReconcileFailure(
recon_result.error or "reconcile failed",
code=recon_result.error_code or "RECONCILE_FAILED",
)
logger.warning(
f"TRACE_PERF [{req_id}] S1_RECONCILE_FAILED "
f"code={error.code} candidates={len(new_memory_texts)} stored=0"
)
return [], error, {"add": 0, "supersede": 0, "update": 0, "total": 0}, {
"prompt": recon_result.prompt_tokens,
"completion": recon_result.completion_tokens,
"total": recon_result.total_tokens,
}
# Episode:本次写入归属的内部会话 id(跨 write 把"一件事"归到一起)。
# 只数 user 侧记忆(与 rolling summary 只数 pending_user_count 一致)——
# agent 侧记忆若参与计数会把 episode 提前撑到上限、切得过碎,白付摘要的 token。
# 放在 reconcile 成功之后:失败的写入不能推进 episode 计数。
episode_id = await self._resolve_episode_id(
request, _count_user_owned(new_memories_meta)
)
session_episode_id = self._session_episode_id(request)
if session_episode_id:
episode_id = session_episode_id
# 分类统计
_op_counts = {}
@@ -1308,16 +1337,7 @@ class MemoryWriter(WritePipeline):
logger.info(
f"TRACE_PERF [{req_id}] S1_RECONCILE_DONE "
f"ops={len(recon_result.ops)} candidates={len(new_memory_texts)} | {_op_counts_str}"
+ (f" | DEGRADED({recon_result.degraded_reason})"
if getattr(recon_result, "degraded", False) else "")
)
if getattr(recon_result, "degraded", False):
# 走了 ADD-all 兜底:内容保住了,但没做去重/演化判定。必须可观测,
# 否则线上只会看到"记忆变多了"而不知道为什么。
logger.warning(
f"[write] reconcile degraded → ADD-all "
f"({recon_result.degraded_reason}), {len(recon_result.ops)} memories stored as-is"
)
# 逐条 op 详情由 reconciler 的 "[reconciler] ops detail" 单条 JSON list 输出,
# 这里不再逐条打印(避免重复刷屏)。
@@ -2507,12 +2527,10 @@ class MemoryWriter(WritePipeline):
response.extra["extract_tokens"] = {"prompt": 0, "completion": 0, "total": 0}
if recon_error is not None:
response.success = False
response.error_code = 502
response.error_message = f"[RECONCILE_FAILED] {recon_error}"
response.extra["agent_status"] = "failed"
response.extra["agent_error"] = recon_error
response.extra["agent_nodes"] = 0
# 与 write() 一致:reconcile 失败不落任何记忆,报失败供调用方重跑。
self._mark_reconcile_failure(response, recon_error)
response.extra["agent_mode"] = "extracted"
logger.warning(f"[write_extracted] reconcile failed: {recon_error}")
return response
response.success = True
@@ -3531,12 +3549,11 @@ class MemoryWriter(WritePipeline):
response.extra["reconcile_ops"] = ops_counts
response.extra["reconcile_tokens"] = recon_tokens
if recon_error is not None:
# stored_ids 为空:reconcile 失败时一条新记忆都不落库,write()
# 随后回滚 L1_RAW(retry_safe=True),换个 request_id 重跑即可。
response.extra["agent_stored_ids"] = stored_ids
self._mark_agent_failure(
response,
error_code="RECONCILE_FAILED",
error_message=recon_error,
agent_nodes=len(stored_ids),
self._mark_reconcile_failure(
response, recon_error, agent_nodes=len(stored_ids),
)
logger.warning(f"[write] reconcile failed: {recon_error}")
return
@@ -3659,6 +3676,29 @@ class MemoryWriter(WritePipeline):
response.error_code = 502
response.error_message = f"[{code}] {message}"
@classmethod
def _mark_reconcile_failure(
cls,
response: WriteResponse,
recon_error: str,
*,
agent_nodes: int = 0,
) -> None:
"""reconcile 失败:agent_error_code 恒为 RECONCILE_FAILED,具体原因
(LLM_UNAVAILABLE / EMPTY_RESPONSE / JSON_PARSE_FAILED / RECONCILE_EXCEPTION)
放在 reconcile_error_code。"""
reason = getattr(recon_error, "code", "") or "RECONCILE_FAILED"
response.extra["reconcile_error_code"] = reason
cls._mark_agent_failure(
response,
error_code="RECONCILE_FAILED",
error_message=(
f"{reason}: {recon_error}"
if reason != "RECONCILE_FAILED" else str(recon_error)
),
agent_nodes=agent_nodes,
)
async def _handle_agent_failure(self, request, response, agent_result, tracer_span, extract_timing: Optional[Dict[str, Any]] = None) -> None:
"""处理 agent extract 失败的情况。"""
if agent_result is None:
+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.12`。MemHub 源码版本为 `1.0.6`
(build 37),GitHub 仓库为
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.13`。MemHub 源码版本为 `1.0.6`
(build 40),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.12`。SDK 配置、权限边界及 search
SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.13`。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.12",
"versionInfo": "2.0.13",
"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.12
## hy-memory 2.0.13
License: Apache-2.0
+3
View File
@@ -17,11 +17,14 @@ const READ_ONLY_PATHS = [
"/api/v1/model-profiles",
"/api/v1/source-grants",
"/api/v1/sources",
"/api/v2/home-projection",
"/api/v2/home-summary",
"/api/v2/raw",
"/api/v2/memory-library",
"/api/v2/user-profile",
"/api/v2/user-profile/versions",
"/api/v1/settings/memory-mode",
"/api/v1/settings/memory-mode/plan",
"/api/v1/settings/memory-features",
"/api/v1/settings/ui-language",
"/api/v2/evolve",
+175
View File
@@ -1,4 +1,5 @@
import type {
HomeProjectionV1,
ImportIssue,
ImportJob,
LiveSyncHealth,
@@ -26,6 +27,9 @@ export const MEMORY_STATUSES = [
const ID_MAX = 128;
const LABEL_MAX = 256;
const LIST_MAX = 500;
const USER_ID = /^[A-Za-z0-9._-]{1,64}$/;
const ISO_TIME =
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
const LIVE_SYNC_STATES = [
"idle",
"running",
@@ -406,6 +410,153 @@ export function validateHomeSummary(value: unknown): SdkHomeSummary | null {
return summary as unknown as SdkHomeSummary;
}
/** `GET /api/v2/home-projection`: the mode-aware home content plane. */
export function validateHomeProjection(
value: unknown,
): HomeProjectionV1 | null {
const projection = exactRecord(value, [
"projection_version",
"user_id",
"effective_mode",
"capabilities",
"visible_count",
"display_state",
"latest",
"observed_at",
]);
if (!projection) return null;
const mode = projection.effective_mode;
const capabilities = exactRecord(projection.capabilities, [
"content_plane",
"generation_required",
"graph_enabled",
"evolve_available",
]);
if (
projection.projection_version !== 1 ||
typeof projection.user_id !== "string" ||
!USER_ID.test(projection.user_id) ||
!oneOf(mode, ["lite", "pro", "ultra"] as const) ||
!capabilities ||
capabilities.content_plane !== (mode === "lite" ? "raw" : "memory") ||
capabilities.generation_required !== (mode !== "lite") ||
capabilities.graph_enabled !== (mode === "ultra") ||
capabilities.evolve_available !== (mode === "ultra") ||
count(projection.visible_count) === null ||
!oneOf(projection.display_state, [
"empty",
"excerpt",
"no_user_excerpt",
"no_displayable_memory",
] as const) ||
!isoTime(projection.observed_at) ||
!validProjectionLatest(projection.latest, mode)
) {
return null;
}
const visible = projection.visible_count as number;
const latest = projection.latest as Record<string, unknown> | null;
if (
(visible === 0 &&
(latest !== null || projection.display_state !== "empty")) ||
(visible > 0 && projection.display_state === "empty") ||
(latest === null &&
["excerpt", "no_user_excerpt"].includes(
projection.display_state as string,
)) ||
(latest?.type === "raw" &&
projection.display_state !== latest.display_state) ||
(latest?.type === "memory" && projection.display_state !== "excerpt")
) {
return null;
}
return projection as unknown as HomeProjectionV1;
}
function validProjectionLatest(
value: unknown,
mode: "lite" | "pro" | "ultra",
): boolean {
if (value === null) return true;
const base = [
"type",
"time",
"time_basis",
"excerpt",
"truncated",
"source_id",
];
const candidate = record(value);
if (!candidate) return false;
const keys =
candidate.type === "raw"
? [...base, "raw_id", "session_id", "display_state"]
: [...base, "memory_id", "scene", "kind", "variant"];
const latest = exactRecord(value, keys);
if (
!latest ||
!(latest.time === null || isoTime(latest.time)) ||
![null, "conversation", "import", "write", "update"].includes(
latest.time_basis as null | string,
) ||
text(latest.excerpt, 200) === null ||
typeof latest.truncated !== "boolean" ||
text(latest.source_id, ID_MAX) === null
) {
return false;
}
if (latest.type === "raw") {
return (
mode === "lite" &&
nonemptyText(latest.raw_id, ID_MAX) &&
nonemptyText(latest.session_id, LABEL_MAX) &&
oneOf(latest.display_state, ["excerpt", "no_user_excerpt"] as const) &&
(latest.display_state !== "no_user_excerpt" ||
(latest.excerpt === "" && latest.truncated === false))
);
}
return (
latest.type === "memory" &&
mode !== "lite" &&
nonemptyText(latest.memory_id, ID_MAX) &&
oneOf(latest.scene, ["chat", "working", "coding"] as const) &&
oneOf(latest.kind, ["chat", "experience"] as const) &&
[null, "working", "coding"].includes(latest.variant as null | string) &&
((latest.kind === "chat" &&
latest.scene === "chat" &&
latest.variant === null) ||
(latest.kind === "experience" &&
latest.scene === latest.variant &&
oneOf(latest.scene, ["working", "coding"] as const)))
);
}
/** The bounded `/api/v2/raw?limit=1` envelope used only for its real total. */
export function validateRawTotal(value: unknown): number | null {
const page = exactRecord(value, [
"user_id",
"items",
"total",
"offset",
"limit",
"time_filtered",
]);
if (
!page ||
typeof page.user_id !== "string" ||
!USER_ID.test(page.user_id) ||
!Array.isArray(page.items) ||
page.items.length > 1 ||
count(page.total) === null ||
page.offset !== 0 ||
page.limit !== 1 ||
page.time_filtered !== false
) {
return null;
}
return page.total as number;
}
function identifiers(value: unknown): string[] | null {
return list(value ?? [], (item) => text(item, ID_MAX));
}
@@ -430,6 +581,30 @@ function record(value: unknown): Record<string, unknown> | null {
: null;
}
function exactRecord(
value: unknown,
keys: readonly string[],
): Record<string, unknown> | null {
const item = record(value);
return item &&
Object.keys(item).length === keys.length &&
keys.every((key) => Object.hasOwn(item, key))
? item
: null;
}
function isoTime(value: unknown): value is string {
return (
typeof value === "string" &&
ISO_TIME.test(value) &&
Number.isFinite(Date.parse(value))
);
}
function nonemptyText(value: unknown, max: number): value is string {
return typeof value === "string" && value.length > 0 && value.length <= max;
}
function text(value: unknown, max: number): string | null {
return typeof value === "string" && value.length <= max ? value : null;
}
@@ -250,6 +250,20 @@ describe("models activate with an embedding change", () => {
code: "embedding_switch_required",
});
});
it("does not treat a domain 404 as an old-Core route miss", async () => {
transport.post.mockResolvedValue({
status: 404,
data: { detail: "embedding_switch_not_found" },
});
const result = await capturedRun(["models", "activate", PROFILE, "--json"]);
expect(result.code).toBe(1);
expect(
transport.post.mock.calls.some(([url]) =>
String(url).endsWith("/activate"),
),
).toBe(false);
});
});
describe("models switch and spaces", () => {
+6 -1
View File
@@ -9,6 +9,7 @@
* makes it live. The CLI shows the plan, asks, starts the job and follows it.
*/
import { isBareNotFound } from "@one-memory/operations";
import type { CoreReader } from "./core-client.js";
import type {
CoreDeleteOperations,
@@ -387,7 +388,11 @@ async function requestPlan(
}
if (outcome.state === "rejected") {
// A Core before the registry has no plan route: activation as before.
if (outcome.http_status === 404 && outcome.code === "http_404") {
if (
outcome.http_status === 404 &&
outcome.code === "http_404" &&
isBareNotFound(outcome.body)
) {
return { state: "unsupported" };
}
const plan = parsePlan(record(outcome.body)?.plan);
+11 -1
View File
@@ -161,6 +161,7 @@ const CORE = new Map<string, { status: number; data: unknown }>([
blocked_stages: [
{ kind: "extract", reason: "model_missing", count: 2 },
],
pipeline: { mode: "pro", configured_mode: "pro" },
},
},
],
@@ -202,7 +203,9 @@ const READ_ONLY_PATHS = [
"/api/v1/live-sync",
"/api/v1/memory-processing",
"/api/v1/model-profiles",
"/api/v2/home-projection",
"/api/v2/home-summary",
"/api/v2/raw",
"/api/v2/memory-library",
"/api/v2/memories/",
"/api/v2/user-profile",
@@ -257,6 +260,9 @@ describe("CLI-20 status summary", () => {
expect(report.sections.model).toMatchObject({ state: "ok", profiles: 1 });
expect(report.sections.memories).toMatchObject({
state: "ok",
content_plane: "memory",
visible_count: 42,
effective_mode: "pro",
memory_count: 42,
});
expect(report.summary.state).toBe("ok");
@@ -903,7 +909,10 @@ describe("CLI-21 memory reads", () => {
expect(isReadOnlyPath("/api/v2/memories/ask")).toBe(false);
expect(isReadOnlyPath("/api/v2/memories/m1")).toBe(true);
expect(isReadOnlyPath("/api/v2/memory-library")).toBe(true);
expect(isReadOnlyPath("/api/v2/home-projection")).toBe(true);
expect(isReadOnlyPath("/api/v2/home-summary")).toBe(true);
expect(isReadOnlyPath("/api/v2/raw")).toBe(true);
expect(isReadOnlyPath("/api/v1/settings/memory-mode/plan")).toBe(true);
// The retired v1 data plane is no longer a CLI read.
expect(isReadOnlyPath("/api/v1/memories/m1")).toBe(false);
expect(isReadOnlyPath("/api/v1/home-summary")).toBe(false);
@@ -1085,7 +1094,8 @@ describe("malformed success responses", () => {
it("refuses a model profile whose string fields are not strings", async () => {
transport.get.mockImplementation(async (url: string) => {
if (url !== "/api/v1/model-profiles") return CORE.get(url);
if (url !== "/api/v1/model-profiles")
return CORE.get(url) ?? { status: 404, data: { detail: "not_found" } };
return {
status: 200,
data: [
+137
View File
@@ -147,3 +147,140 @@ describe("status and doctor on an unfinished upgrade migration", () => {
expect(report.runtime.log_file).toBeUndefined();
});
});
describe("mode-aware status content projection", () => {
const runtime = {
state: "running" as const,
code: "runtime_verified",
host: "127.0.0.1",
port: 30001,
};
const processing = (mode: "lite" | "pro" | "ultra") => ({
pending: 0,
fresh_pending: 0,
backlog_pending: 0,
outside_window_pending: 0,
running: 0,
waiting_for_model: 0,
completed: 0,
failed: 0,
pipeline: { mode, configured_mode: mode },
});
const projection = {
projection_version: 1,
user_id: "local",
effective_mode: "lite",
capabilities: {
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
},
visible_count: 7,
display_state: "excerpt",
latest: {
type: "raw",
raw_id: "raw-1",
session_id: "session-1",
display_state: "excerpt",
time: null,
time_basis: null,
excerpt: "hello",
truncated: false,
source_id: "cursor",
},
observed_at: "2026-11-04T00:00:00Z",
};
it("uses the projection without reading either legacy count endpoint", async () => {
const calls: string[] = [];
const reader: CoreReader = async <T>(path: string) => {
calls.push(path);
const data =
path === "/api/v2/home-projection"
? projection
: path === "/api/v1/memory-processing"
? processing("lite")
: null;
return { state: "ok", data } as ReadOutcome<T>;
};
const report = await composeStatusReport({
dataDir: "/tmp/isolated",
runtime,
background: null,
reader,
});
expect(report.sections.memories).toEqual({
state: "ok",
content_plane: "raw",
visible_count: 7,
effective_mode: "lite",
raw_count: 7,
});
expect(calls).not.toContain("/api/v2/raw");
expect(calls).not.toContain("/api/v2/home-summary");
expect(renderStatusText(report, "MemHub")).toContain(
"原始对话:原始对话 7",
);
});
it("falls back to the real raw total only for an old Lite Core", async () => {
const reader: CoreReader = async <T>(path: string) => {
if (path === "/api/v2/home-projection")
return { state: "not_found", code: "http_404" } as ReadOutcome<T>;
if (path === "/api/v1/memory-processing")
return {
state: "ok",
data: processing("lite"),
} as ReadOutcome<T>;
if (path === "/api/v2/raw")
return {
state: "ok",
data: {
user_id: "local",
items: [],
total: 9,
offset: 0,
limit: 1,
time_filtered: false,
},
} as ReadOutcome<T>;
return { state: "unavailable", code: "x" } as ReadOutcome<T>;
};
const report = await composeStatusReport({
dataDir: "/tmp/isolated",
runtime,
background: null,
reader,
});
expect(report.sections.memories).toMatchObject({
state: "ok",
content_plane: "raw",
visible_count: 9,
raw_count: 9,
});
expect(report.sections.memories).not.toHaveProperty("memory_count");
});
it("rejects extra projection fields instead of falling back or leaking them", async () => {
const reader: CoreReader = async <T>(path: string) =>
({
state: "ok",
data:
path === "/api/v2/home-projection"
? { ...projection, secret: "must-not-leak" }
: processing("lite"),
}) as ReadOutcome<T>;
const report = await composeStatusReport({
dataDir: "/tmp/isolated",
runtime,
background: null,
reader,
});
expect(report.sections.memories).toEqual({
state: "invalid",
code: "response_invalid",
});
expect(JSON.stringify(report)).not.toContain("must-not-leak");
});
});
+86 -8
View File
@@ -1,4 +1,5 @@
import type {
HomeProjectionV1,
LiveSyncHealth,
MemoryProcessingSummary,
ModelProfile,
@@ -12,10 +13,12 @@ import { sdkMigrationLogPath } from "@one-memory/runtime-control";
import type { CoreReader, ReadOutcome } from "./core-client.js";
import {
type SdkHomeSummary,
validateHomeProjection,
validateHomeSummary,
validateLiveSync,
validateModelProfiles,
validateProcessing,
validateRawTotal,
validateSources,
} from "./core-responses.js";
import { describeEmbeddingSwitchSummary } from "./embedding-switch-flow.js";
@@ -211,12 +214,12 @@ export async function composeStatusReport(options: {
for (const name of SECTION_NAMES) sections[name] = { state, code };
} else {
const reader = options.reader;
const [sources, sync, processing, model, memories] = await Promise.all([
const [sources, sync, processing, model, projection] = await Promise.all([
reader<unknown>("/api/v1/sources"),
reader<unknown>("/api/v1/live-sync"),
reader<unknown>("/api/v1/memory-processing"),
reader<unknown>("/api/v1/model-profiles"),
reader<unknown>("/api/v2/home-summary"),
reader<unknown>("/api/v2/home-projection"),
]);
sections.sources = section(sources, validateSources, describeSources);
sections.sync = section(sync, validateLiveSync, describeSync);
@@ -228,11 +231,7 @@ export async function composeStatusReport(options: {
pipeline =
processing.state === "ok" ? describePipeline(processing.data) : null;
sections.model = section(model, validateModelProfiles, describeModel);
sections.memories = section(
memories,
validateHomeSummary,
describeMemories,
);
sections.memories = await memorySection(reader, projection, pipeline);
}
// A bridge we could not inspect is unknown, not broken and not fine.
const bridge: StatusDocument["bridge"] =
@@ -312,7 +311,9 @@ export function renderStatusText(
];
for (const name of SECTION_NAMES) {
lines.push(
`${SECTION_LABELS[name]}:${sectionLine(report.sections[name])}`,
`${sectionLabel(name, report.sections[name])}:${sectionLine(
report.sections[name],
)}`,
);
}
if (report.pipeline) lines.push(`记忆模式:${pipelineLine(report.pipeline)}`);
@@ -409,8 +410,17 @@ const SECTION_LINE_OMITTED = new Set([
"embedding_switch",
"llm_backoff_until",
"llm_backoff_error",
"content_plane",
"visible_count",
"effective_mode",
]);
function sectionLabel(name: string, section: Section | undefined): string {
return name === "memories" && section?.content_plane === "raw"
? "原始对话"
: SECTION_LABELS[name];
}
function sectionLine(section: Section | undefined): string {
if (!section) return "未检查";
if (section.state === "not_checked") return "未检查";
@@ -462,6 +472,10 @@ function fieldLabel(key: string): string {
profiles: "配置",
active: "当前",
memory_count: "记忆",
raw_count: "原始对话",
visible_count: "可见",
content_plane: "内容层",
effective_mode: "模式",
};
return labels[key] ?? key;
}
@@ -595,6 +609,70 @@ function describeMemories(summary: SdkHomeSummary): Record<string, unknown> {
};
}
async function memorySection(
reader: CoreReader,
projection: ReadOutcome<unknown>,
pipeline: PipelineSummary | null,
): Promise<Section> {
if (projection.state === "ok") {
const parsed = validateHomeProjection(projection.data);
return parsed === null
? { state: "invalid", code: "response_invalid" }
: { state: "ok", ...describeProjection(parsed) };
}
if (projection.state !== "not_found") {
return {
state: projection.state === "denied" ? "denied" : "unavailable",
code: projection.code,
};
}
if (pipeline === null) {
return { state: "unavailable", code: "effective_mode_unavailable" };
}
if (pipeline.mode === "lite") {
const raw = await reader<unknown>("/api/v2/raw", {
limit: 1,
offset: 0,
});
if (raw.state !== "ok") {
return {
state: raw.state === "denied" ? "denied" : "unavailable",
code: raw.code,
};
}
const total = validateRawTotal(raw.data);
return total === null
? { state: "invalid", code: "response_invalid" }
: {
state: "ok",
content_plane: "raw",
visible_count: total,
effective_mode: "lite",
raw_count: total,
};
}
const legacy = await reader<unknown>("/api/v2/home-summary");
return section(legacy, validateHomeSummary, (summary) => ({
content_plane: "memory",
visible_count: summary.memory_count,
effective_mode: pipeline.mode,
...describeMemories(summary),
}));
}
function describeProjection(
projection: HomeProjectionV1,
): Record<string, unknown> {
return {
content_plane: projection.capabilities.content_plane,
visible_count: projection.visible_count,
effective_mode: projection.effective_mode,
...(projection.capabilities.content_plane === "raw"
? { raw_count: projection.visible_count }
: { memory_count: projection.visible_count }),
};
}
function sum(
sources: SourceResponse[],
key: "imported_conversations" | "imported_messages",
+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.12",
"hy-memory==2.0.13",
# 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",
+419 -70
View File
@@ -264,7 +264,13 @@ from one_memory.model_request_audit import (
ModelRequestAuditStore,
)
from one_memory.model_request_budget import BudgetedModelTransport, ModelRequestBudget
from one_memory.onboarding import OnboardingState, OnboardingStore
from one_memory.onboarding import (
OnboardingCompletedError,
OnboardingProgressUpdate,
OnboardingState,
OnboardingStore,
)
from one_memory.raw_turns import RawDisplayTurn, raw_display_turns
from one_memory.recall import (
GovernedRecallTarget,
RecallReceipt,
@@ -323,6 +329,13 @@ from one_memory.ui_language import (
UiLanguage,
sdk_language,
)
from one_memory.ui_projection import (
HomeProjectionCapabilities,
HomeProjectionV1,
home_capabilities,
raw_list_filters,
read_home_projection,
)
from one_memory.user_scope import (
DEFAULT_USER_ID,
MULTI_USER_SOURCES,
@@ -592,24 +605,37 @@ 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":
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_turn_messages(record.content)
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,
)
@@ -622,9 +648,9 @@ class SdkRawPage(BaseModel):
total: int = Field(ge=0)
offset: int = Field(ge=0)
limit: int = Field(ge=1, le=500)
#: Set when a time filter was applied to this page in Core (the SDK list
#: has none): `items` is the page's rows inside the range, `total` stays
#: the SDK's count before that filter.
unit: Literal["raw", "turn"] = "raw"
#: Set when a time filter was applied in Core. For storage `raw`, `total`
#: remains the SDK count; for product `turn`, it is the filtered turn count.
time_filtered: bool = False
@@ -640,11 +666,6 @@ class SdkLayerCounts(BaseModel):
statuses: tuple[str, ...] | None = None
#: Raw lifecycle states that are still a kept conversation. Pro marks a raw
#: SHADOW once extraction succeeds; lite leaves it ACTIVE.
RAW_STATUSES = ("active", "shadow")
class MemoryProcessingBlock(BaseModel):
kind: str
reason: str
@@ -740,6 +761,8 @@ class MemoryModeState(BaseModel):
mode: MemoryMode
#: The saved setting (default pro).
configured_mode: MemoryMode
#: True once a person chose a mode, including explicitly choosing pro.
explicitly_configured: bool
#: `ONE_MEMORY_GRAPH_STORE` (dev) forces ultra over the setting.
forced_by_env: bool
modes: tuple[MemoryMode, ...] = MEMORY_MODES
@@ -769,6 +792,29 @@ class MemoryModeChange(MemoryModeState):
effects: tuple[MemoryModeEffect, ...] = ()
MemoryModeBlocker = Literal[
"active_model_required",
"embedding_maintenance_in_progress",
"memory_lifecycle_busy",
"graph_store_unavailable",
]
MemoryModeWarning = Literal["forced_by_env", "effective_no_change"]
class MemoryModePlan(BaseModel):
"""Shared validation result used by plan and PUT."""
model_config = ConfigDict(extra="forbid")
current: MemoryMode
target: MemoryMode
effective_target: MemoryMode
capabilities: HomeProjectionCapabilities
effects: tuple[MemoryModeEffect, ...] = ()
warnings: tuple[MemoryModeWarning, ...] = ()
blockers: tuple[MemoryModeBlocker, ...] = ()
class EvolveScheduleUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -1440,10 +1486,15 @@ def create_app(
service=app.state.evolve_service,
schedule=lambda: _config_store(app).evolve_schedule(),
users_with_memories=lambda: _users_with_memories(app),
lifecycle_lock=app.state.memory_mode_lock,
lifecycle_open=lambda: (
_embedding_writes_open(app) and not app.state.memory_graph_backfill.running
),
)
app.state.memory_graph_backfill = MemoryGraphBackfill(
coordinator=app.state.sdk_write_coordinator,
state=app.state.evolve_state_store,
lifecycle_open=lambda: _embedding_writes_open(app),
)
# C2 owns the single production budget (handle 5s + reserve 250ms,
# total 5.25s, lease 10s, renew 1s, claim 250ms). The composition
@@ -1465,22 +1516,15 @@ def create_app(
async def admit_hy_invocation(
source_id: str,
requires_generation: bool,
mark_invoked: Callable[[], Awaitable[bool]],
) -> bool:
"""Serialize pause with the last boundary before an HY call."""
source_lock = app.state.source_permission_locks.setdefault(
source_id, asyncio.Lock()
return await _admit_pipeline_invocation(
app,
source_id,
requires_generation,
mark_invoked,
)
async with app.state.sedimentation_lock, source_lock:
if not _config_store(app).sedimentation_enabled():
raise RuntimeError("model_sedimentation_disabled")
if not _embedding_writes_open(app):
# Re-checked at the last boundary: a switch that began
# after the claim keeps this job queued, unsent.
raise RuntimeError("embedding_switch_in_progress")
if not _source_writes_on(app, source_id):
raise RuntimeError("model_content_source_disabled")
return await mark_invoked()
memory_write_drain = DurableWriteDrain(
memory_write_queue,
@@ -1529,7 +1573,7 @@ def create_app(
# therefore skipped on every drain tick — the note stayed
# "saved, waiting" with no task behind it, forever.
source_claim_enabled=lambda source_id: _sedimentation_claim_enabled(app, source_id),
model_claim_allowance=app.state.model_backoff.allowance,
model_claim_allowance=lambda: _model_claim_allowance(app),
)
app.state.memory_write_drain = memory_write_drain
@@ -1842,6 +1886,16 @@ def create_app(
name="one-memory-durable-write-drain",
)
async def resume_graph_backfill() -> None:
saved = app.state.evolve_state_store.graph_build()
if saved.state != "running":
return
if _memory_mode(app) != "ultra":
app.state.evolve_state_store.set_graph_build(GraphBuildState())
return
users = await asyncio.to_thread(_known_user_ids, app)
app.state.memory_graph_backfill.recover(users)
async def start_evolve_schedule() -> None:
# Idle outside ultra: each tick asks the mode first.
app.state.evolve_scheduler.start()
@@ -1930,7 +1984,14 @@ def create_app(
# served from the live space throughout.
if sdk:
await step("embedding_spaces", resume_embedding_spaces)
await step("graph_backfill", resume_graph_backfill)
await step("write_drain", start_write_drain)
if sdk and _memory_mode(app) == "lite":
# A previous pro run may have parked jobs while model
# sedimentation was off. Lite needs no generation model:
# readmit them in the background so startup can finish
# while raw conversations begin appearing.
_resume_sedimentation(app)
if sdk:
await step("evolve_schedule", start_evolve_schedule)
else:
@@ -2484,6 +2545,21 @@ def create_app(
def get_onboarding() -> OnboardingState:
return _onboarding_store(app).get()
@app.api_route(
"/api/v1/onboarding/progress",
methods=["PATCH", "PUT"],
response_model=OnboardingState,
dependencies=protected,
)
async def update_onboarding_progress(
update: OnboardingProgressUpdate,
) -> OnboardingState:
"""Persist only resumable onboarding choices; secrets are never accepted."""
try:
return await asyncio.to_thread(_onboarding_store(app).update_progress, update)
except OnboardingCompletedError as error:
raise HTTPException(status_code=409, detail=str(error)) from error
@app.post(
"/api/v1/onboarding/complete",
response_model=OnboardingState,
@@ -3829,6 +3905,30 @@ def create_app(
app.state.memory_task_store.pause_profile_for_model(profile_id)
return Response(status_code=204)
@app.get(
"/api/v2/home-projection",
response_model=HomeProjectionV1,
dependencies=strict,
)
async def home_projection(
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
) -> HomeProjectionV1:
"""The mode-neutral homepage read model owned by Core."""
try:
mode = _memory_mode(app)
handle = _sdk_client_handle(app)
if isinstance(handle, ActiveProfileReadHandle):
client = await handle.client_for_mode(mode)
else:
client = await handle.client()
return await read_home_projection(
client,
user_id=user_id,
mode=mode,
)
except Exception as error:
raise _sdk_http_error(error, "home_projection_read_failed") from None
@app.get(
"/api/v2/home-summary",
response_model=SdkHomeSummary,
@@ -3995,7 +4095,9 @@ def create_app(
client = await _sdk_client_handle(app).client()
def count(layer: str) -> int:
layer_statuses = chosen or (list(RAW_STATUSES) if layer == "l1_raw" else ["active"])
layer_statuses = chosen or (
raw_list_filters()["statuses"] if layer == "l1_raw" else ["active"]
)
raw = client.list_memories(
user_id=user_id,
layers=[layer],
@@ -4353,6 +4455,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,
@@ -4362,6 +4474,7 @@ def create_app(
source: list[str] | None = Query(default=None), # noqa: B008
offset: int = Query(default=0, ge=0),
limit: int = Query(default=100, ge=1, le=500),
unit: Literal["raw", "turn"] = Query(default="raw"),
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
exclude_sources: list[str] | None = Query(default=None), # noqa: B008
session_id: list[str] | None = Query(default=None), # noqa: B008
@@ -4375,38 +4488,162 @@ 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`.
"""
chosen_statuses = _library_values(
statuses or [], MEMORY_STATUSES, "memory_status_unknown"
) or list(RAW_STATUSES)
chosen_statuses = (
_library_values(statuses or [], MEMORY_STATUSES, "memory_status_unknown")
or raw_list_filters()["statuses"]
)
sources = split_csv(source)
excluded = split_csv(exclude_sources)
sessions = split_csv(session_id)
try:
client = await _sdk_client_handle(app).client()
value = await asyncio.to_thread(
client.list_memories,
user_id=user_id,
layers=["l1_raw"],
statuses=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)
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,
"order": order if unit == "raw" else "desc",
}
if unit == "raw":
value = await asyncio.to_thread(
client.list_memories,
**query,
offset=offset,
limit=limit,
)
page = HySdkRawPage.from_memory_list(value, limit=limit, offset=offset)
records = page.raw
else:
cache_key = (
user_id,
tuple(sorted(chosen_statuses)),
tuple(sorted(sources)),
tuple(sorted(excluded)),
tuple(sorted(sessions)),
)
if len(raw_turn_cache_locks) > 64:
for stale_key, stale_lock in tuple(raw_turn_cache_locks.items()):
if (
stale_key not in raw_turn_cache
and not stale_lock.locked()
):
raw_turn_cache_locks.pop(stale_key, None)
lock = raw_turn_cache_locks.setdefault(cache_key, asyncio.Lock())
async with lock:
for _attempt in range(3):
value = await asyncio.to_thread(
client.list_memories,
**query,
offset=0,
limit=500,
)
first_page = HySdkRawPage.from_memory_list(
value, limit=500, offset=0
)
signature = tuple(
(record.raw_id, record.status) for record in first_page.raw
)
cached = raw_turn_cache.get(cache_key)
if (
cached is not None
and cached[0] == first_page.total
and cached[1] == signature
):
projected_turns = cached[2]
break
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
@@ -4421,6 +4658,7 @@ def create_app(
total=page.total,
offset=offset,
limit=limit,
unit="raw",
time_filtered=time_filtered,
)
@@ -4675,6 +4913,15 @@ def create_app(
"""The SDK mode (lite / pro / ultra): configured, effective, and why."""
return _memory_mode_state(app)
@app.post(
"/api/v1/settings/memory-mode/plan",
response_model=MemoryModePlan,
dependencies=strict,
)
def plan_memory_mode(request: MemoryModeUpdate) -> MemoryModePlan:
"""Validate and explain a mode change without mutating anything."""
return _memory_mode_plan(app, request.mode)
@app.put(
"/api/v1/settings/memory-mode",
response_model=MemoryModeChange,
@@ -4693,13 +4940,14 @@ def create_app(
added to the graph once, in the background (`graph_build`). No raw is
ever deleted, in any mode. See docs/contracts/memory-mode-evolve-v1.md.
"""
if request.mode == "ultra" and importlib.util.find_spec("kuzu") is None:
raise HTTPException(status_code=409, detail="graph_store_unavailable")
store = _config_store(app)
async with app.state.memory_mode_lock:
previous_effective = _memory_mode(app)
plan = _memory_mode_plan(app, request.mode)
if plan.blockers:
raise HTTPException(status_code=409, detail=plan.blockers[0])
previous_effective = plan.current
previous = store.memory_mode()
if previous != request.mode or "memory_mode" not in store.document().model_fields_set:
if previous != request.mode or not store.memory_mode_explicitly_configured():
await asyncio.to_thread(store.set_memory_mode, request.mode)
effective = _memory_mode(app)
backfill: bool | None = None
@@ -4718,6 +4966,8 @@ def create_app(
outcome="succeeded",
details={"mode": effective, "previous_mode": previous_effective},
)
if effective == "lite" and effective != previous_effective:
_resume_sedimentation(app)
return MemoryModeChange(
**_memory_mode_state(app).model_dump(),
previous_mode=previous_effective,
@@ -4862,14 +5112,18 @@ def create_app(
in flight and holds their next writes until it ends, and repeats
while the SDK says there is more to do (up to 20 rounds).
"""
try:
return _evolve_service(app).start(
request.user_id,
agent_id=request.agent_id,
max_batches=request.max_batches,
)
except EvolveModeError:
raise HTTPException(status_code=409, detail="memory_mode_not_ultra") from None
async with app.state.memory_mode_lock:
_refuse_pipeline_change_during_embedding_maintenance(app)
if app.state.memory_graph_backfill.running:
raise HTTPException(status_code=409, detail="memory_lifecycle_busy")
try:
return _evolve_service(app).start(
request.user_id,
agent_id=request.agent_id,
max_batches=request.max_batches,
)
except EvolveModeError:
raise HTTPException(status_code=409, detail="memory_mode_not_ultra") from None
@app.get(
"/api/v2/evolve",
@@ -4987,16 +5241,16 @@ def create_app(
"""Start or stop model-driven sedimentation, keeping the configuration.
Stopping is the user's cost control: nothing further goes to the model
and already queued work waits rather than failing, so starting again
resumes it. Importing conversations is not affected either way.
and already queued work remains pending rather than failing. Starting
again lets the normal claim tick continue it; this route never broadly
re-admits permission/model parked jobs. Importing conversations is not
affected either way.
"""
store = _config_store(app)
changed = False
async with app.state.sedimentation_lock:
previous = store.sedimentation_enabled()
if previous is not request.enabled:
await asyncio.to_thread(store.set_sedimentation_enabled, request.enabled)
changed = True
diagnostic_event(
level="info",
component="memory.sedimentation",
@@ -5004,11 +5258,6 @@ def create_app(
outcome="succeeded",
details={"enabled": request.enabled},
)
if changed and request.enabled:
# Work parked by the stop is picked up again rather than waiting
# for the next natural dispatch, but not on this request's time:
# see _resume_sedimentation.
_resume_sedimentation(app)
return SedimentationState(enabled=store.sedimentation_enabled())
@app.post(
@@ -5808,10 +6057,19 @@ def _resume_sedimentation(app: FastAPI) -> None:
"""
running = cast("asyncio.Task[None] | None", getattr(app.state, "sedimentation_resume", None))
if running is not None and not running.done():
app.state.sedimentation_resume_again = True
return
async def resume_until_stable() -> None:
while True:
app.state.sedimentation_resume_again = False
await _sdk_memory_runtime(app).resume_allowed()
if not app.state.sedimentation_resume_again:
return
app.state.sedimentation_resume = watched_task(
asyncio.get_running_loop(),
lambda: _sdk_memory_runtime(app).resume_allowed(),
resume_until_stable,
component="memory.sedimentation",
name="one-memory-sedimentation-resume",
)
@@ -6178,14 +6436,65 @@ def _memory_mode(app: FastAPI) -> MemoryMode:
def _memory_mode_state(app: FastAPI) -> MemoryModeState:
mode = _memory_mode(app)
store = _config_store(app)
return MemoryModeState(
mode=mode,
configured_mode=_config_store(app).memory_mode(),
configured_mode=store.memory_mode(),
explicitly_configured=store.memory_mode_explicitly_configured(),
forced_by_env=graph_store_enabled(),
graph_build=app.state.evolve_state_store.graph_build(),
)
def _memory_mode_plan(app: FastAPI, target: MemoryMode) -> MemoryModePlan:
"""One validator for previews and mutations, in stable blocker order."""
current = _memory_mode(app)
effective_target = cast(MemoryMode, effective_memory_mode(target))
blockers: list[MemoryModeBlocker] = []
if not _embedding_writes_open(app):
blockers.append("embedding_maintenance_in_progress")
if (
effective_target != current
and effective_target in {"pro", "ultra"}
and _model_profile_store(app).active_identity() is None
):
blockers.append("active_model_required")
if effective_target != current and (
_evolve_service(app).running() or app.state.memory_graph_backfill.running
):
blockers.append("memory_lifecycle_busy")
if (
effective_target != current
and effective_target == "ultra"
and importlib.util.find_spec("kuzu") is None
):
blockers.append("graph_store_unavailable")
warnings: list[MemoryModeWarning] = []
if graph_store_enabled():
warnings.append("forced_by_env")
if effective_target == current:
warnings.append("effective_no_change")
graph_backfill: bool | None = None
if effective_target == "ultra" and current != "ultra":
graph_backfill = not app.state.memory_graph_backfill.running
effects = memory_mode_effects(
current,
effective_target,
graph_backfill=graph_backfill,
)
return MemoryModePlan(
current=current,
target=target,
effective_target=effective_target,
capabilities=home_capabilities(effective_target),
effects=() if blockers else effects,
warnings=tuple(warnings),
blockers=tuple(blockers),
)
def _evolve_service(app: FastAPI) -> EvolveService:
return cast(EvolveService, app.state.evolve_service)
@@ -6559,7 +6868,7 @@ def _sedimentation_claim_enabled(app: FastAPI, source_id: str) -> bool:
"""
return (
_config_store(app).sedimentation_enabled()
_pipeline_writes_enabled(app)
and _embedding_writes_open(app)
and (_memory_mode(app) == "lite" or _model_profile_store(app).active_identity() is not None)
and _history_import_gate_released(app)
@@ -6568,6 +6877,40 @@ def _sedimentation_claim_enabled(app: FastAPI, source_id: str) -> bool:
)
async def _admit_pipeline_invocation(
app: FastAPI,
source_id: str,
requires_generation: bool,
mark_invoked: Callable[[], Awaitable[bool]],
) -> bool:
"""Serialize the final source and pipeline checks with the durable mark."""
source_lock = app.state.source_permission_locks.setdefault(source_id, asyncio.Lock())
async with app.state.sedimentation_lock, source_lock:
if requires_generation and not _config_store(app).sedimentation_enabled():
raise RuntimeError("model_sedimentation_disabled")
if not _embedding_writes_open(app):
raise RuntimeError("embedding_switch_in_progress")
if not _source_writes_on(app, source_id):
raise RuntimeError("model_content_source_disabled")
return await mark_invoked()
def _pipeline_writes_enabled(app: FastAPI) -> bool:
"""Raw-only lite writes run even when model sedimentation is paused."""
return _memory_mode(app) == "lite" or _config_store(app).sedimentation_enabled()
def _model_claim_allowance(app: FastAPI) -> int | None:
"""A failed generation model never pauses raw-only lite ingestion."""
backoff = cast(ModelBackoff, app.state.model_backoff)
if _memory_mode(app) == "lite":
return backoff.allowance(kind="embedding")
return backoff.allowance()
def _user_evolving(app: FastAPI, user_id: str) -> bool:
"""Whether evolve holds this user's writes right now."""
coordinator = getattr(app.state, "sdk_write_coordinator", None)
@@ -6612,6 +6955,12 @@ def _refuse_write_during_embedding_switch(app: FastAPI) -> None:
raise HTTPException(status_code=409, detail="embedding_switch_in_progress")
def _refuse_pipeline_change_during_embedding_maintenance(app: FastAPI) -> None:
"""Keep mode/evolve lifecycle changes out of embedding maintenance."""
if not _embedding_writes_open(app):
raise HTTPException(status_code=409, detail="embedding_maintenance_in_progress")
_EMBEDDING_PATCH_FIELDS = frozenset(
{"embedding_model", "embedding_provider_kind", "embedding_base_url", "embedding_dimensions"}
)
@@ -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
from one_memory.sdk_search_options import SdkSearchFilters, SearchLane
from one_memory.semantic_deadline import SemanticMemoryHit
from one_memory.user_scope import owns
@@ -120,6 +121,9 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
sources = raw_lane_sources(authority.raw_sources, value.include_sources)
if sources is None:
return ()
candidate_limit = (
min(value.limit * 10, 200) if value.raw_unit == "turn" else value.limit
)
raw = await asyncio.to_thread(
client.search,
value.query,
@@ -127,7 +131,7 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
"user_ids": [value.user_id],
"agent_ids": [],
"include_sources": sources,
"limit": value.limit,
"limit": candidate_limit,
"min_score": 0.0,
"schema_min_score": 0.0,
"schema_limit": 0,
@@ -135,27 +139,59 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
"scene": "chat",
"reader": HY_MEMORY_READER,
"layers": ["l1_raw"],
"statuses": ["active", "shadow"],
**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,
)
for hit in hits
)
items: list[SdkRawContextItem] = []
for hit in hits:
turns = raw_display_turns(hit) if value.raw_unit == "turn" else ()
if turns:
query = value.query.casefold()
lexical = tuple(turn for turn in turns if query in turn.content.casefold())
# A batch-level embedding cannot identify which contained
# turn matched semantically. Return only turns grounded by
# the query text instead of assigning every sibling the
# batch score and showing unrelated conversations.
for turn in lexical:
items.append(
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=(
datetime.fromtimestamp(hit.memory_at, tz=UTC)
if hit.memory_at is not None
else None
),
score=hit.score,
)
)
else:
items.append(
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,
)
)
if len(items) >= value.limit:
break
return tuple(items[: value.limit])
async def memory_lane() -> tuple[SdkMemoryContextItem, ...]:
# lite extracts nothing: its search answers raw turns, which the
@@ -126,15 +126,23 @@ def embedding_switch_router(dependencies: Sequence[Depends]) -> APIRouter:
@router.post("/api/v1/embedding-switches", status_code=202, response_model=None)
async def start_embedding_switch(value: EmbeddingSwitchStartRequest, request: Request) -> Any:
try:
job = await _service(request).start_switch(
value.profile_id,
expected_revision=value.expected_revision,
plan_token=value.plan_token,
requested_by=value.requested_by,
)
except EmbeddingSwitchError as error:
return refusal(error.code, plan=error.plan)
# One lifecycle lock orders mode/evolve/backfill and embedding
# maintenance. Without it each endpoint could pass its own preflight
# and then begin concurrently.
async with request.app.state.memory_mode_lock:
evolve = request.app.state.evolve_service
backfill = request.app.state.memory_graph_backfill
if evolve.running() or backfill.running:
return refusal("memory_lifecycle_busy")
try:
job = await _service(request).start_switch(
value.profile_id,
expected_revision=value.expected_revision,
plan_token=value.plan_token,
requested_by=value.requested_by,
)
except EmbeddingSwitchError as error:
return refusal(error.code, plan=error.plan)
return job.model_dump(mode="json")
@router.get("/api/v1/embedding-switches/current")
@@ -14,7 +14,7 @@ from pathlib import Path
from typing import Literal, cast
HY_MEMORY_DISTRIBUTION = "hy-memory"
HY_MEMORY_VERSION = "2.0.12"
HY_MEMORY_VERSION = "2.0.13"
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.12 is pinned exactly).
# Relies on the pinned SDK's internals (hy-memory==2.0.13 is pinned exactly).
store = CodingMemoryStore.__new__(CodingMemoryStore)
store._db_path = str(path)
store._conn = None
@@ -75,6 +75,9 @@ class HySdkMemoryRecord(BaseModel):
# Epoch seconds. `updated_at` falls back to `created_at`.
created_at: int | None = None
updated_at: int | None = None
# Actual scene=all ordering timestamp before the public fallback above.
# Internal-only: callers keep the existing SdkMemoryRecord wire shape.
sort_updated_at: int | None = Field(default=None, exclude=True, repr=False)
@classmethod
def from_sdk(cls, raw: object) -> HySdkMemoryRecord:
@@ -95,6 +98,10 @@ class HySdkMemoryRecord(BaseModel):
if created_at is None:
created_at = gmt_created
updated_at = _epoch(raw.get("updated_at"))
modified_at = _epoch(raw.get("gmt_modified"))
sort_updated_at = (
modified_at if modified_at is not None else updated_at
)
return cls(
memory_id=str(raw["memory_id"]),
content=str(raw["content"]),
@@ -116,6 +123,7 @@ class HySdkMemoryRecord(BaseModel):
gmt_created=gmt_created,
created_at=created_at,
updated_at=updated_at if updated_at is not None else created_at,
sort_updated_at=sort_updated_at,
)
except (KeyError, TypeError, ValueError) as error:
raise HySdkReadError("hy_memory_record_shape_unknown") from error
@@ -180,6 +188,7 @@ class HySdkMemoryRecord(BaseModel):
solution=solution,
created_at=created_at,
updated_at=updated_at if updated_at is not None else created_at,
sort_updated_at=updated_at,
)
except (KeyError, TypeError, ValueError) as error:
raise HySdkReadError("hy_memory_record_shape_unknown") from error
@@ -10,7 +10,10 @@ from pydantic import JsonValue
from one_memory.input_sanitization import sanitize_model_input
_REMINDER_TAGS = r"additional_data|system[-_]reminder|working_memory_reminder"
_CONTEXT_TAGS = r"user_info|rules|agent_transcripts|system[-_]notification|pipeline_log"
_CONTEXT_TAGS = (
r"user_info|rules|agent_transcripts|attached_files|image_local_path|"
r"system[-_]notification|pipeline_log"
)
_TAG_ATTRIBUTES = r"""(?:\s+[\w:-]+\s*=\s*(?:"[^"<>]*"|'[^'<>]*'))*\s*"""
_HOST_PREFIX = re.compile(rf"<(?P<tag>{_REMINDER_TAGS}|{_CONTEXT_TAGS}){_TAG_ATTRIBUTES}>")
_REMINDER_START = re.compile(rf"<(?P<tag>{_REMINDER_TAGS}){_TAG_ATTRIBUTES}>")
@@ -465,12 +465,16 @@ class EvolveScheduler:
service: EvolveService,
schedule: Callable[[], EvolveSchedule],
users_with_memories: Callable[[], Awaitable[Iterable[str]]],
lifecycle_lock: asyncio.Lock | None = None,
lifecycle_open: Callable[[], bool] = lambda: True,
now: Callable[[], datetime] = lambda: datetime.now().astimezone(),
check_seconds: float = SCHEDULE_CHECK_SECONDS,
) -> None:
self.service = service
self.schedule = schedule
self.users_with_memories = users_with_memories
self.lifecycle_lock = lifecycle_lock
self.lifecycle_open = lifecycle_open
self._now = now
self.check_seconds = check_seconds
self._task: asyncio.Task[None] | None = None
@@ -492,7 +496,15 @@ class EvolveScheduler:
if not evolve_due(now, schedule, last, anchor):
continue
try:
receipt = self.service.start(user_id, trigger="scheduled")
if self.lifecycle_lock is None:
if not self.lifecycle_open():
break
receipt = self.service.start(user_id, trigger="scheduled")
else:
async with self.lifecycle_lock:
if not self.lifecycle_open():
break
receipt = self.service.start(user_id, trigger="scheduled")
except EvolveModeError:
break
await self.service.wait(receipt.run_id)
@@ -533,10 +545,12 @@ class MemoryGraphBackfill:
*,
coordinator: EvolveCoordinator,
state: EvolveStateStore,
lifecycle_open: Callable[[], bool] = lambda: True,
now: Callable[[], datetime] = lambda: datetime.now(UTC),
) -> None:
self.coordinator = coordinator
self.state = state
self.lifecycle_open = lifecycle_open
self._now = now
self._task: asyncio.Task[None] | None = None
@@ -557,6 +571,35 @@ class MemoryGraphBackfill:
self._task = asyncio.create_task(self._run(users), name="one-memory-graph-backfill")
return state
def recover(self, user_ids: Iterable[str]) -> GraphBuildState:
"""Resume an interrupted backfill without repeating completed users."""
if self.running:
return self.state.graph_build()
saved = self.state.graph_build()
if saved.state != "running":
return saved
users = tuple(dict.fromkeys((*saved.users.keys(), *user_ids)))
pending = tuple(
user_id
for user_id in users
if saved.users.get(user_id, GraphBuildUser()).state != "succeeded"
)
resumed = saved.model_copy(deep=True)
for user_id in pending:
resumed.users[user_id] = GraphBuildUser()
if not pending:
resumed.state = "succeeded"
resumed.finished_at = self._now()
self.state.set_graph_build(resumed)
return resumed
resumed.state = "running"
resumed.finished_at = None
self.state.set_graph_build(resumed)
self._task = asyncio.create_task(
self._run(pending), name="one-memory-graph-backfill-recovery"
)
return resumed
async def wait(self) -> None:
if self._task is not None:
await asyncio.shield(self._task)
@@ -564,6 +607,8 @@ class MemoryGraphBackfill:
async def _run(self, users: tuple[str, ...]) -> None:
state = self.state.graph_build()
for user_id in users:
while not self.lifecycle_open():
await asyncio.sleep(0.2)
state.users[user_id] = GraphBuildUser(state="running")
await asyncio.to_thread(self.state.set_graph_build, state.model_copy(deep=True))
try:
@@ -153,6 +153,11 @@ class ConfigStore:
with self._lock:
return self._document.memory_mode
def memory_mode_explicitly_configured(self) -> bool:
"""Whether a person chose the mode, including choosing the default."""
with self._lock:
return "memory_mode" in self._document.model_fields_set
def set_memory_mode(self, mode: MemoryMode) -> ConfigDocument:
if mode not in MEMORY_MODES:
raise ValueError("invalid_memory_mode")
@@ -2540,7 +2540,7 @@ async def execute_job(
evidence: EvidenceStore,
owner: str | None = None,
source_writes_permitted: Callable[[str], bool] | None = None,
admit_hy_invocation: Callable[[str, Callable[[], Awaitable[bool]]], Awaitable[bool]]
admit_hy_invocation: Callable[[str, bool, Callable[[], Awaitable[bool]]], Awaitable[bool]]
| None = None,
model_failures: Callable[[], AbstractContextManager[ModelAttempt | None]] | None = None,
) -> None:
@@ -2593,7 +2593,7 @@ async def execute_job(
hy_invoked = False
async def mark_hy_invocation() -> None:
async def mark_hy_invocation(requires_generation: bool) -> None:
"""Persist the durable boundary immediately before the real HY call."""
nonlocal hy_invoked
if hy_invoked:
@@ -2621,7 +2621,7 @@ async def execute_job(
return await in_the_background(queue.mark_invoked, job_id, owner=owner)
if admit_hy_invocation is not None:
admitted = await admit_hy_invocation(source_id, mark)
admitted = await admit_hy_invocation(source_id, requires_generation, mark)
else:
if source_writes_permitted is not None and not source_writes_permitted(source_id):
raise RuntimeError("model_content_source_disabled")
@@ -3261,11 +3261,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(
@@ -3274,11 +3272,9 @@ class DurableWriteDrain:
type(error).__name__,
)
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,6 +3283,36 @@ 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_background(
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)
@@ -3324,7 +3350,26 @@ 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__,
)
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
@@ -3349,7 +3394,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:
@@ -3190,6 +3190,51 @@ async def _061_write_job_retries(cursor: Any) -> None:
await cursor.execute(statement)
ONBOARDING_PROGRESS_MIGRATION_VERSION = 62
def onboarding_progress_ddl(dialect: str = "mysql") -> str:
"""Append resumable onboarding choices without rewriting migration 12."""
if dialect == "sqlite":
return """
ALTER TABLE onboarding_state
ADD COLUMN mode_intent TEXT NULL;
ALTER TABLE onboarding_state
ADD COLUMN setup_step TEXT NULL;
ALTER TABLE onboarding_state
ADD COLUMN model_profile_id TEXT NULL;
"""
return """
ALTER TABLE onboarding_state
ADD COLUMN mode_intent VARCHAR(16) NULL AFTER updated_at;
ALTER TABLE onboarding_state
ADD COLUMN setup_step VARCHAR(16) NULL AFTER mode_intent;
ALTER TABLE onboarding_state
ADD COLUMN model_profile_id VARCHAR(36) NULL AFTER setup_step;
"""
async def _062_onboarding_progress(cursor: Any) -> None:
if _dialect(cursor) == "sqlite":
existing = await cursor.columns("onboarding_state")
statements = _split_statements(onboarding_progress_ddl("sqlite"))
for column, statement in zip(
("mode_intent", "setup_step", "model_profile_id"),
statements,
strict=True,
):
if column not in existing:
await cursor.execute(statement)
return
for statement in _split_statements(onboarding_progress_ddl("mysql")):
try:
await cursor.execute(statement)
except Exception as error: # noqa: BLE001 - MySQL driver error type varies
if _is_duplicate_column(error):
continue
raise
def _split_statements(sql: str) -> list[str]:
"""Split a multi-statement script, dropping blanks, PRAGMA and comments.
@@ -3490,4 +3535,10 @@ MIGRATIONS: tuple[Migration, ...] = (
("memory_note_requests",),
),
Migration(61, "write_job_retries", _061_write_job_retries),
Migration(
ONBOARDING_PROGRESS_MIGRATION_VERSION,
"onboarding_progress",
_062_onboarding_progress,
("onboarding_state",),
),
)
@@ -247,15 +247,17 @@ class ModelBackoff:
and self._clock() < self._until
)
def allowance(self) -> int | None:
"""How many model jobs may be claimed now: None for no limit.
def allowance(self, *, kind: str | None = None) -> int | None:
"""How many matching provider jobs may be claimed now.
0 while paused; once the pause is over, one probe at a time until it
reports or `PROBE_HOLD_SECONDS` pass.
reports or `PROBE_HOLD_SECONDS` pass. ``kind`` keeps a failed chat
model from pausing raw-only work that needs embeddings, while an
embedding outage still paces that work.
"""
now = self._clock()
with self._lock:
if self._failures == 0:
if self._failures == 0 or (kind is not None and kind not in self._failing):
return None
if now < self._until or now < self._probe_until:
return 0
+171 -32
View File
@@ -4,9 +4,10 @@ from collections.abc import Iterator
from contextlib import contextmanager
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from typing import Any, Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field, field_validator
from one_memory.database import (
take_the_write_lock_first,
@@ -14,11 +15,25 @@ from one_memory.database import (
from one_memory.database_access import main_database
#: Schema owned by `memory_server.migrations` (migration 12). No local copy.
from one_memory.memory_server.migrations import onboarding_state_ddl
from one_memory.memory_server.sql_compat import execute, is_mysql
from one_memory.memory_server.migrations import (
onboarding_progress_ddl,
onboarding_state_ddl,
)
from one_memory.memory_server.sql_compat import execute, is_mysql, row_to_dict
from one_memory.memory_server.store_binding import store_for
SCHEMA = onboarding_state_ddl("sqlite")
ONBOARDING_COLUMNS = (
"singleton",
"status",
"selected_source_ids_json",
"started_at",
"completed_at",
"updated_at",
"mode_intent",
"setup_step",
"model_profile_id",
)
#: The start/complete upserts, per engine.
#:
@@ -51,11 +66,13 @@ START_MYSQL = """
updated_at = VALUES(updated_at)
"""
COMPLETE_UPDATE_SQLITE = (
"UPDATE onboarding_state SET status = 'completed', completed_at = ?, updated_at = ? "
"UPDATE onboarding_state SET status = 'completed', completed_at = ?, updated_at = ?, "
"mode_intent = NULL, setup_step = NULL, model_profile_id = NULL "
"WHERE singleton = 1"
)
COMPLETE_UPDATE_MYSQL = (
"UPDATE onboarding_state SET status = 'completed', completed_at = %s, updated_at = %s "
"UPDATE onboarding_state SET status = 'completed', completed_at = %s, updated_at = %s, "
"mode_intent = NULL, setup_step = NULL, model_profile_id = NULL "
"WHERE singleton = 1"
)
COMPLETE_INSERT_SQLITE = (
@@ -71,13 +88,27 @@ COMPLETE_INSERT_MYSQL = (
RESET_UPDATE_SQLITE = (
"UPDATE onboarding_state SET status = 'not_started', "
"selected_source_ids_json = '[]', started_at = NULL, completed_at = NULL, "
"mode_intent = NULL, setup_step = NULL, model_profile_id = NULL, "
"updated_at = ? WHERE singleton = 1"
)
RESET_UPDATE_MYSQL = (
"UPDATE onboarding_state SET status = 'not_started', "
"selected_source_ids_json = '[]', started_at = NULL, completed_at = NULL, "
"mode_intent = NULL, setup_step = NULL, model_profile_id = NULL, "
"updated_at = %s WHERE singleton = 1"
)
ENSURE_PROGRESS_SQLITE = (
"INSERT INTO onboarding_state ("
"singleton,status,selected_source_ids_json,started_at,completed_at,updated_at"
") VALUES (1,'not_started','[]',NULL,NULL,?) "
"ON CONFLICT(singleton) DO NOTHING"
)
ENSURE_PROGRESS_MYSQL = (
"INSERT INTO onboarding_state ("
"singleton,status,selected_source_ids_json,started_at,completed_at,updated_at"
") VALUES (1,'not_started','[]',NULL,NULL,%s) "
"ON DUPLICATE KEY UPDATE singleton=VALUES(singleton)"
)
class OnboardingState(BaseModel):
@@ -87,6 +118,35 @@ class OnboardingState(BaseModel):
selected_source_ids: tuple[str, ...]
started_at: datetime | None
completed_at: datetime | None
mode_intent: Literal["lite", "pro"] | None
setup_step: Literal["review", "mode", "model"] | None
model_profile_id: str | None
class OnboardingProgressUpdate(BaseModel):
"""Only non-sensitive, resumable choices accepted from the onboarding UI."""
model_config = ConfigDict(frozen=True, extra="forbid")
mode_intent: Literal["lite", "pro"] | None = None
setup_step: Literal["review", "mode", "model"] | None = None
model_profile_id: str | None = Field(default=None, min_length=36, max_length=36)
@field_validator("model_profile_id")
@classmethod
def validate_model_profile_id(cls, value: str | None) -> str | None:
if value is None:
return None
try:
if str(UUID(value)) != value:
raise ValueError
except (ValueError, TypeError, AttributeError):
raise ValueError("invalid_model_profile_id") from None
return value
class OnboardingCompletedError(RuntimeError):
pass
class OnboardingStore:
@@ -103,12 +163,31 @@ class OnboardingStore:
"""
with self._connect(initialize=True) as connection:
execute(connection, is_mysql(self._store), SCHEMA)
if not is_mysql(self._store):
existing = {
str(row[1]) for row in connection.execute("PRAGMA table_info(onboarding_state)")
}
statements = tuple(
statement.strip()
for statement in onboarding_progress_ddl("sqlite").split(";")
if statement.strip()
)
for column, statement in zip(
("mode_intent", "setup_step", "model_profile_id"),
statements,
strict=True,
):
if column not in existing:
connection.execute(statement)
def get(self) -> OnboardingState:
mysql = is_mysql(self._store)
with self._connect() as connection:
row = execute(
connection, mysql, "SELECT * FROM onboarding_state WHERE singleton = 1"
connection,
mysql,
"SELECT " + ", ".join(ONBOARDING_COLUMNS) + " "
"FROM onboarding_state WHERE singleton = 1",
).fetchone()
if row is None:
return OnboardingState(
@@ -116,8 +195,11 @@ class OnboardingStore:
selected_source_ids=(),
started_at=None,
completed_at=None,
mode_intent=None,
setup_step=None,
model_profile_id=None,
)
return self._to_state(row)
return self._to_state(row, mysql=mysql)
def start(self, source_ids: tuple[str, ...]) -> OnboardingState:
now = datetime.now(UTC).isoformat()
@@ -150,6 +232,43 @@ class OnboardingStore:
)
return self.get()
def update_progress(self, update: OnboardingProgressUpdate) -> OnboardingState:
fields = tuple(
field
for field in ("mode_intent", "setup_step", "model_profile_id")
if field in update.model_fields_set
)
if not fields:
state = self.get()
if state.status == "completed":
raise OnboardingCompletedError("onboarding_completed")
return state
mysql = is_mysql(self._store)
placeholder = "%s" if mysql else "?"
assignments = [f"{field} = {placeholder}" for field in fields]
assignments.append(f"updated_at = {placeholder}")
values = [getattr(update, field) for field in fields]
now = datetime.now(UTC).isoformat()
values.append(now)
with self._connect(writing=True) as connection:
execute(
connection,
mysql,
ENSURE_PROGRESS_MYSQL if mysql else ENSURE_PROGRESS_SQLITE,
(now,),
)
updated = execute(
connection,
mysql,
"UPDATE onboarding_state SET "
+ ", ".join(assignments)
+ " WHERE singleton = 1 AND status != 'completed'",
tuple(values),
)
if updated.rowcount != 1:
raise OnboardingCompletedError("onboarding_completed")
return self.get()
def reset(self) -> OnboardingState:
"""Return onboarding to its first-run state, touching nothing else.
@@ -207,33 +326,53 @@ class OnboardingStore:
yield connection
@staticmethod
def _to_state(row: Any) -> OnboardingState:
"""Read the row by position, so it works for `sqlite3.Row` and tuples.
`SELECT *` makes the column order part of the contract here, which is
why the query is `SELECT *` and the indices are named: relying on
`row["..."]` would only work on SQLite, and relying on position alone
without saying which column is which would be unreadable.
Timestamps are a further engine difference: SQLite hands back the stored
string, MySQL hands back a `datetime`. Accepting both is not padding —
parsing a `datetime` raises, and treating a string as one would be
silently wrong.
"""
# singleton, status, selected_source_ids_json, started_at, completed_at, updated_at
raw_ids = json.loads(row[2])
def _to_state(row: Any, *, mysql: bool) -> OnboardingState:
"""Map SQLite rows and MySQL tuples through the shared database adapter."""
if mysql:
if not isinstance(row, tuple):
raise ValueError("onboarding_row_shape_invalid")
elif not isinstance(row, sqlite3.Row):
raise ValueError("onboarding_row_shape_invalid")
if len(row) != len(ONBOARDING_COLUMNS):
raise ValueError("onboarding_row_shape_invalid")
if mysql:
values = dict(zip(ONBOARDING_COLUMNS, tuple(row), strict=True))
else:
values = row_to_dict(row, ONBOARDING_COLUMNS, mysql=False)
if set(values) != set(ONBOARDING_COLUMNS):
raise ValueError("onboarding_row_shape_invalid")
_as_datetime(values["updated_at"], mysql=mysql, nullable=False)
raw_ids = json.loads(values["selected_source_ids_json"])
return OnboardingState(
status=row[1],
status=values["status"],
selected_source_ids=tuple(value for value in raw_ids if isinstance(value, str)),
started_at=_as_datetime(row[3]),
completed_at=_as_datetime(row[4]),
started_at=_as_datetime(values["started_at"], mysql=mysql),
completed_at=_as_datetime(values["completed_at"], mysql=mysql),
mode_intent=values["mode_intent"],
setup_step=values["setup_step"],
model_profile_id=values["model_profile_id"],
)
def _as_datetime(value: object) -> datetime | None:
"""SQLite stores these as text; MySQL returns `datetime` objects."""
if value is None or value == "":
def _as_datetime(
value: object,
*,
mysql: bool,
nullable: bool = True,
) -> datetime | None:
"""Validate the engine's timestamp shape and normalize it to UTC."""
if value is None:
if not nullable:
raise ValueError("onboarding_datetime_invalid")
return None
if isinstance(value, datetime):
return value
return datetime.fromisoformat(str(value))
if mysql:
if not isinstance(value, datetime):
raise ValueError("onboarding_datetime_invalid")
parsed = value
else:
if not isinstance(value, str) or value == "":
raise ValueError("onboarding_datetime_invalid")
parsed = datetime.fromisoformat(value)
if parsed.tzinfo is None:
return parsed.replace(tzinfo=UTC)
return parsed.astimezone(UTC)
@@ -0,0 +1,62 @@
"""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
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
)
@@ -25,7 +25,7 @@ from one_memory.hy_runtime_config import HY_MEMORY_READER
from one_memory.hy_sdk_runtime import SdkClientHandle, sdk_relevance_floor
from one_memory.hy_sdk_types import HySdkError, parse_sdk_search
from one_memory.memory_queue import ExtractionWindowPreset, QueueDepths
from one_memory.memory_server.durable_queue import DurableWriteQueue
from one_memory.memory_server.durable_queue import WAITING_FOR_MODEL, DurableWriteQueue
from one_memory.sdk_write_processor import SdkWriteCoordinator
from one_memory.semantic_deadline import SemanticMemoryHit
@@ -309,7 +309,10 @@ class SdkMemoryRuntime:
return []
async def resume_allowed(self) -> None:
await asyncio.to_thread(self.write_queue.readmit_parked)
await asyncio.to_thread(
self.write_queue.readmit_parked,
states=(WAITING_FOR_MODEL,),
)
def accepted_backfill_status(self) -> None:
return None
@@ -94,6 +94,11 @@ class ActiveProfileReadHandle(SdkClientHandle):
handle = await self.current_handle()
return self._guarded(handle, await handle.client())
async def client_for_mode(self, mode: str) -> Any:
"""Build/read with the caller's one mode snapshot, without rereading it."""
handle = await self.current_handle(mode=mode)
return self._guarded(handle, await handle.client())
def _guarded(self, handle: SdkClientHandle, client: Any) -> Any:
"""A remote embedding's searches report `embedding_unavailable` (no fallback)."""
profile = getattr(handle, "profile", None)
@@ -111,15 +116,15 @@ class ActiveProfileReadHandle(SdkClientHandle):
),
)
async def current_handle(self) -> SdkClientHandle:
async def current_handle(self, *, mode: str | None = None) -> SdkClientHandle:
"""The handle of the live space, held for the asking task."""
space, identity = await in_sdk_read(self._resolve)
mode, features = self.memory_mode(), self.current_features()
selected_mode, features = mode or self.memory_mode(), self.current_features()
language = self.current_language()
key = (
space.space_id if space is not None else "",
*identity,
mode,
selected_mode,
features,
language,
)
@@ -128,7 +133,9 @@ class ActiveProfileReadHandle(SdkClientHandle):
if handle is None:
# Only on a change of space or profile: resolving a key can
# reach the system keychain, so it is not done on every read.
handle = await in_sdk_read(self._build, space, identity, mode, features, language)
handle = await in_sdk_read(
self._build, space, identity, selected_mode, features, language
)
self._handles[key] = handle
self._retired.update(value for name, value in self._handles.items() if name != key)
self._handles = {key: handle}
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import re
import threading
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
from contextlib import asynccontextmanager
@@ -22,11 +23,12 @@ from one_memory.hy_sdk_runtime import (
SdkClientHandle,
builtin_retrieval_snapshot,
language_kwargs,
retrieval_view,
)
from one_memory.imports.models import EvidenceContentKind, EvidenceOrigin, EvidenceRecord
from one_memory.input_sanitization import sanitize_model_input
from one_memory.memory_features import FeatureOverrides
from one_memory.model_profiles import ModelProfileSnapshot, ModelProfileStore
from one_memory.model_profiles import ModelProfileSnapshot, ModelProfileStore, ProviderKind
from one_memory.secrets import SecretStore, SecretStoreUnavailableError
PRODUCT_AGENT_ID = "all-apps"
@@ -111,7 +113,7 @@ class SdkWriteProcessor:
records: tuple[EvidenceRecord, ...],
*,
request_id: str,
before_hy_invocation: Callable[[], Awaitable[None]],
before_hy_invocation: Callable[[bool], Awaitable[None]],
) -> SdkWriteReceipt:
if not records:
raise RuntimeError("hy_memory_task_without_inputs")
@@ -138,7 +140,7 @@ class SdkWriteProcessor:
if extracted and not self._lite:
contents = [sanitize_model_input(record.verbatim) for record in records]
await before_hy_invocation()
await before_hy_invocation(True)
result = await _write(
request_id,
client.add_extracted,
@@ -166,7 +168,7 @@ class SdkWriteProcessor:
]
if not messages:
raise RuntimeError("hy_memory_task_without_inputs")
await before_hy_invocation()
await before_hy_invocation(not self._lite)
result = await _write(
request_id,
client.add,
@@ -282,10 +284,8 @@ class SdkWriteCoordinator:
records: tuple[EvidenceRecord, ...],
*,
request_id: str,
before_hy_invocation: Callable[[], Awaitable[None]],
before_hy_invocation: Callable[[bool], Awaitable[None]],
) -> SdkWriteReceipt:
if not self.sedimentation_enabled():
raise RuntimeError("model_sedimentation_disabled")
if not self.writes_open():
# An embedding switch paused writes: the drain puts the job back.
raise RuntimeError("embedding_switch_in_progress")
@@ -296,9 +296,8 @@ class SdkWriteCoordinator:
self._writing[user_id] = self._writing.get(user_id, 0) + 1
try:
async with self._active_handle() as handle:
return await SdkWriteProcessor(
handle, lite=getattr(handle, "mode", None) == "lite"
).process_sdk_job(
lite = getattr(handle, "mode", None) == "lite"
return await SdkWriteProcessor(handle, lite=lite).process_sdk_job(
records,
request_id=request_id,
before_hy_invocation=before_hy_invocation,
@@ -465,24 +464,27 @@ class SdkWriteCoordinator:
return (builtin.profile_id, builtin.revision), builtin, None, space
identity = (space.profile_id, space.profile_revision)
profile = self.profiles.snapshot(*identity)
try:
secret = self.profiles.resolve_snapshot_secret(*identity, self.secrets)
except SecretStoreUnavailableError:
raise RuntimeError("secure_storage_unavailable") from None
if profile.has_api_key and secret is None:
raise RuntimeError("model_secret_unavailable")
return identity, profile, secret, space
view = retrieval_view(profile)
secret: str | None = None
if view.embedding_provider_kind is ProviderKind.OPENAI_COMPATIBLE:
try:
secret = self.profiles.resolve_snapshot_secret(*identity, self.secrets)
except SecretStoreUnavailableError:
raise RuntimeError("secure_storage_unavailable") from None
if secret is None:
raise RuntimeError("model_secret_unavailable")
return identity, view, secret, space
def _read_active_credentials(
self,
mode: str = "pro",
) -> tuple[tuple[str, int], ModelProfileSnapshot, str | None, EmbeddingSpace | None]:
if mode == "lite":
# A lite write is an embedding-only view of the live space. Never
# resolve or inject the generation model endpoint or its secret.
return self._lite_credentials()
identity = self.profiles.active_identity()
if identity is None:
if mode == "lite":
# lite stores raw turns with one embedding and calls no model:
# the live space's own, so they land where reads look.
return self._lite_credentials()
raise RuntimeError("model_profile_not_active")
profile = self.profiles.snapshot(*identity)
space = self._live_space() if self.spaces is not None else None
@@ -568,11 +570,36 @@ def _is_extracted_content(record: EvidenceRecord) -> bool:
)
#: The SDK's stable failure codes are upper snake case (`RECONCILE_FAILED`).
_SDK_FAILURE_CODE = re.compile(r"[A-Z][A-Z0-9_]{0,47}")
def sdk_write_failure_code(value: Mapping[object, object]) -> str:
"""The job's `error_code` for a failed SDK write: the SDK's own cause if it gave one.
hy-memory 2.0.13 names why a write failed: `agent_error_code`
(`LLM_ERROR`, `EMPTY_RESPONSE`, `JSON_PARSE_FAILED`, `EMBEDDING_FAILED`,
`RECONCILE_FAILED`) and, for a failed reconcile, `reconcile_error_code`
(`LLM_UNAVAILABLE`, `EMPTY_RESPONSE`, `JSON_PARSE_FAILED`,
`RECONCILE_EXCEPTION`). They become `hy_sdk_reconcile_empty_response`,
`hy_sdk_llm_error`, ... Without a recognisable code it stays
`hy_sdk_write_failed`. Only the code is kept, never the SDK's message.
"""
reconcile = value.get("reconcile_error_code")
if isinstance(reconcile, str) and _SDK_FAILURE_CODE.fullmatch(reconcile):
cause = reconcile.removeprefix("RECONCILE_") or "FAILED"
return f"hy_sdk_reconcile_{cause.lower()}"
agent = value.get("agent_error_code")
if isinstance(agent, str) and _SDK_FAILURE_CODE.fullmatch(agent):
return f"hy_sdk_{agent.lower()}"
return "hy_sdk_write_failed"
def _receipt_from_result(value: object) -> SdkWriteReceipt:
if not isinstance(value, Mapping):
raise RuntimeError("hy_sdk_write_receipt_shape_invalid")
if value.get("success") is not True:
raise RuntimeError("hy_sdk_write_failed")
raise RuntimeError(sdk_write_failure_code(value))
# Read only documented public receipt fields. ``variant`` identifies the
# productivity namespace; IDs from both variants are canonical memory IDs.
@@ -0,0 +1,283 @@
"""Stable UI projections over the HY SDK's mode-specific storage shapes."""
from __future__ import annotations
import asyncio
import re
from datetime import UTC, datetime
from typing import Any, Literal, cast
from pydantic import BaseModel, ConfigDict, Field
from one_memory.hy_sdk_types import (
HySdkMemoryPage,
HySdkMemoryRecord,
HySdkRawPage,
HySdkRawRecord,
raw_turn_messages,
)
from one_memory.imports.sources.parsing import unwrap_host_envelope_with_positions
MemoryMode = Literal["lite", "pro", "ultra"]
TimeBasis = Literal["conversation", "import", "write", "update"]
ProjectionDisplayState = Literal["empty", "excerpt", "no_user_excerpt", "no_displayable_memory"]
RAW_STATUSES = ("active", "shadow")
HOME_PROJECTION_CANDIDATES = 10
HOME_EXCERPT_CODEPOINTS = 200
_SPACE = re.compile(r"\s+")
_CONTROL_ONLY = re.compile(
r"^(?:"
r"<<?\s*(?:image|attachment|system[_-]?reminder|no[_ -]?content)\s*>>?"
r"|\[\s*(?:image|attachment|system[_ -]?reminder|no[_ -]?content)\s*\]"
r")$",
re.IGNORECASE,
)
class HomeProjectionCapabilities(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
content_plane: Literal["raw", "memory"]
generation_required: bool
graph_enabled: bool
evolve_available: bool
class _HomeLatest(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
time: datetime | None
time_basis: TimeBasis | None
excerpt: str
truncated: bool
source_id: str
class HomeRawLatest(_HomeLatest):
type: Literal["raw"] = "raw"
raw_id: str
session_id: str
display_state: Literal["excerpt", "no_user_excerpt"]
class HomeMemoryLatest(_HomeLatest):
type: Literal["memory"] = "memory"
memory_id: str
scene: Literal["chat", "working", "coding"]
# scene=all lists chat rows and productivity experiences; facts are not
# part of that page or its total.
kind: Literal["chat", "experience"]
variant: Literal["working", "coding"] | None
class HomeProjectionV1(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
projection_version: Literal[1] = 1
user_id: str
effective_mode: MemoryMode
capabilities: HomeProjectionCapabilities
visible_count: int = Field(ge=0)
display_state: ProjectionDisplayState
latest: HomeRawLatest | HomeMemoryLatest | None
observed_at: datetime
def raw_list_filters(statuses: list[str] | tuple[str, ...] | None = None) -> dict[str, list[str]]:
"""The one kept-raw definition shared by `/raw` and the home projection."""
return {
"layers": ["l1_raw"],
"statuses": list(statuses or RAW_STATUSES),
}
def home_capabilities(mode: MemoryMode) -> HomeProjectionCapabilities:
return HomeProjectionCapabilities(
content_plane="raw" if mode == "lite" else "memory",
generation_required=mode != "lite",
graph_enabled=mode == "ultra",
evolve_available=mode == "ultra",
)
def raw_user_excerpt(content: str, *, limit: int = HOME_EXCERPT_CODEPOINTS) -> tuple[str, bool]:
"""Return only displayable user text from one SDK raw turn.
Role selection happens before wrapper removal, so assistant/system content
can never become a homepage excerpt after sanitization.
"""
candidates: list[str] = []
for role, text in raw_turn_messages(content):
if role != "user":
continue
envelope = unwrap_host_envelope_with_positions(text)
if envelope.status == "ambiguous":
# An uncertain host envelope may contain system context. Showing
# nothing is safer than guessing which bytes the person authored.
continue
cleaned = envelope.text or ""
cleaned = _SPACE.sub(" ", cleaned).strip()
if not cleaned or _CONTROL_ONLY.fullmatch(cleaned):
continue
candidates.append(cleaned)
joined = " ".join(candidates)
return joined[:limit], len(joined) > limit
def _epoch(value: int | None) -> datetime | None:
return datetime.fromtimestamp(value, tz=UTC) if value is not None else None
def _raw_time(record: HySdkRawRecord) -> tuple[datetime | None, TimeBasis | None]:
if record.memory_at is not None:
return _epoch(record.memory_at), "conversation"
if record.gmt_created is not None:
return _epoch(record.gmt_created), "import"
return None, None
def _memory_time(record: HySdkMemoryRecord) -> tuple[datetime | None, TimeBasis | None]:
# Exactly the scene=all merge key. `memory_at` is event time and does not
# decide which row list_memories returns first.
if record.sort_updated_at is not None:
return _epoch(record.sort_updated_at), "update"
written = record.gmt_created if record.scene == "chat" else record.created_at
if written is not None:
return _epoch(written), "write"
return None, None
def _bounded(text: str) -> tuple[str, bool]:
flattened = _SPACE.sub(" ", text).strip()
return flattened[:HOME_EXCERPT_CODEPOINTS], len(flattened) > HOME_EXCERPT_CODEPOINTS
def project_raw_latest(records: tuple[HySdkRawRecord, ...]) -> HomeRawLatest | None:
if not records:
return None
for record in records:
excerpt, truncated = raw_user_excerpt(record.content)
if excerpt:
at, basis = _raw_time(record)
return HomeRawLatest(
raw_id=record.raw_id,
session_id=record.session_id,
display_state="excerpt",
time=at,
time_basis=basis,
excerpt=excerpt,
truncated=truncated,
source_id=record.source,
)
newest = records[0]
at, basis = _raw_time(newest)
return HomeRawLatest(
raw_id=newest.raw_id,
session_id=newest.session_id,
display_state="no_user_excerpt",
time=at,
time_basis=basis,
excerpt="",
truncated=False,
source_id=newest.source,
)
def project_memory_latest(
records: tuple[HySdkMemoryRecord, ...],
) -> HomeMemoryLatest | None:
record = next(
(
item
for item in records
if item.kind in {"chat", "experience"}
and (
(item.kind == "chat" and item.scene == "chat" and item.variant is None)
or (
item.kind == "experience"
and item.scene in {"working", "coding"}
and item.variant == item.scene
)
)
),
None,
)
if record is None:
return None
excerpt, truncated = _bounded(record.content)
at, basis = _memory_time(record)
return HomeMemoryLatest(
memory_id=record.memory_id,
scene=record.scene,
kind=cast(Literal["chat", "experience"], record.kind),
variant=record.variant,
time=at,
time_basis=basis,
excerpt=excerpt,
truncated=truncated,
source_id=record.source or "",
)
async def read_home_projection(
client: Any,
*,
user_id: str,
mode: MemoryMode,
) -> HomeProjectionV1:
"""Read one authoritative projection; any SDK/shape failure escapes."""
client_mode = getattr(client, "mode", mode)
if client_mode != mode:
raise RuntimeError("home_projection_mode_mismatch")
if mode == "lite":
raw = await asyncio.to_thread(
client.list_memories,
user_id=user_id,
**raw_list_filters(),
limit=HOME_PROJECTION_CANDIDATES,
offset=0,
order="desc",
)
raw_page = HySdkRawPage.from_memory_list(raw, limit=HOME_PROJECTION_CANDIDATES, offset=0)
visible_count = raw_page.total
raw_latest = project_raw_latest(raw_page.raw)
latest: HomeRawLatest | HomeMemoryLatest | None = raw_latest
display_state: ProjectionDisplayState = (
"empty"
if visible_count == 0
else raw_latest.display_state
if raw_latest is not None
else "no_user_excerpt"
)
else:
raw = await asyncio.to_thread(
client.list_memories,
user_id=user_id,
scene="all",
statuses=["active"],
limit=HOME_PROJECTION_CANDIDATES,
offset=0,
order="desc",
)
memory_page, _skipped = HySdkMemoryPage.from_sdk_skipping_invalid(
raw, limit=HOME_PROJECTION_CANDIDATES, offset=0
)
visible_count = memory_page.total
latest = project_memory_latest(memory_page.memories)
display_state = (
"empty"
if visible_count == 0
else "excerpt"
if latest is not None
else "no_displayable_memory"
)
return HomeProjectionV1(
user_id=user_id,
effective_mode=mode,
capabilities=home_capabilities(mode),
visible_count=visible_count,
display_state=display_state,
latest=latest,
observed_at=datetime.now(UTC),
)
+2 -1
View File
@@ -111,6 +111,7 @@
| `test_health.py` | `contract` | 覆盖 a backfill that fails says so rather than disappearing、a failed catch up leaves a traceback behind、configured model without content consent does not start backfill 等 15 个场景。 |
| `test_hermes_gateway_injection.py` | `contract` | Hermes gateway text in `user` rows: notices are skipped, relayed messages unwrapped. |
| `test_history_listing_rotation.py` | `contract` | 覆盖 history listing alternates started and untouched files、history listing drains whichever cohort is longer 场景。 |
| `test_home_projection.py` | `contract` | 覆盖 memory projection covers pro and ultra、memory projection preserves productivity namespace、project raw latest uses import time without conversation time 等 7 个场景。 |
| `test_host_capabilities.py` | `unit` | A plugin does not become realtime on every host or every unknown version. |
| `test_hourly_c1_peak_replay_contract.py` | `contract` | Guard the package peak-replay probe's error accounting. |
| `test_hourly_c1_recall_stage_probe_contract.py` | `contract` | Guard the packaged recall stage probe's command and error semantics. |
@@ -320,4 +321,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. |
共 315 个测试文件。
共 316 个测试文件。
@@ -14,8 +14,8 @@ from pathlib import Path
import pytest
PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live")
EXPECTED_TEST_FILE_COUNT = 315
EXPECTED_TEST_FILES_SHA256 = "681258b2a13ca852e273707071b286afe6b0b2fe537f90a8a2c64d83ec662772"
EXPECTED_TEST_FILE_COUNT = 316
EXPECTED_TEST_FILES_SHA256 = "011482974a26e9d43ba03bb88cbd168e645175beacd96c02ff642bc17dac49da"
# Pure transformations, validation models, parity checks and state machines.
# This list is intentionally small: uncertain files default to contract.
@@ -263,6 +263,48 @@ def test_a_switch_over_the_api_pauses_writes_and_keeps_intake(tmp_path, backend)
asyncio.run(scenario())
def test_graph_backfill_blocks_starting_an_embedding_switch(tmp_path, backend):
backend.store.put(
builtin_base(),
512,
{"m1": ("memory", "local")},
model="bge-small-zh-v1.5",
)
async def scenario():
app = application(tmp_path)
async with app.router.lifespan_context(app), client_for(app) as client:
await recovered(client)
profile = remote_profile(app)
plan = (
await client.post(
"/api/v1/embedding-switches/plan",
json={"profile_id": profile.profile_id},
)
).json()
hold = asyncio.Event()
task = asyncio.create_task(hold.wait())
app.state.memory_graph_backfill._task = task
try:
response = await client.post(
"/api/v1/embedding-switches",
json={
"profile_id": profile.profile_id,
"expected_revision": profile.revision,
"plan_token": plan["plan_token"],
},
)
assert response.status_code == 409
assert response.json()["detail"] == "memory_lifecycle_busy"
assert app.state.embedding_switch.writes_open() is True
finally:
task.cancel()
await asyncio.gather(task, return_exceptions=True)
app.state.memory_graph_backfill._task = None
asyncio.run(scenario())
def test_cancel_over_the_api(tmp_path, backend):
backend.store.put(builtin_base(), 512, {f"m{i}": (f"m {i}", "local") for i in range(4)})
@@ -0,0 +1,397 @@
from __future__ import annotations
import asyncio
import pytest
from fastapi.testclient import TestClient
from pydantic import SecretStr
from one_memory.api import create_app
from one_memory.config import Settings
from one_memory.hy_sdk_types import HySdkReadError
from one_memory.ui_projection import (
HOME_PROJECTION_CANDIDATES,
project_raw_latest,
raw_user_excerpt,
read_home_projection,
)
def raw_row(
raw_id: str,
content: str,
*,
status: str = "active",
memory_at: int | None = 1_700_000_000,
created: int | None = 1_699_000_000,
) -> dict[str, object]:
return {
"memory_id": raw_id,
"content": content,
"layer": "l1_raw",
"status": status,
"user_id": "local",
"agent_id": "all-apps",
"session_id": f"session-{raw_id}",
"source": "cursor",
"memory_at": memory_at,
"gmt_created": created,
"custom": {"private": "not projected"},
}
def chat_row(memory_id: str = "m-1") -> dict[str, object]:
return {
"memory_id": memory_id,
"content": "The user ships on Friday",
"layer": "l2_fact",
"status": "active",
"is_latest": True,
"user_id": "local",
"agent_id": "all-apps",
"session_id": "s-1",
"source": "cursor",
"memory_at": 1_700_000_000,
"gmt_created": 1_699_000_000,
"updated_at": 1_800_000_000,
"scene": "chat",
"kind": "chat",
}
class ProjectionSdk:
def __init__(self, rows: list[dict[str, object]], *, total: int | None = None) -> None:
self.rows = rows
self.total = len(rows) if total is None else total
self.calls: list[dict[str, object]] = []
def list_memories(self, **kwargs):
self.calls.append(kwargs)
if kwargs.get("layers") == ["l1_raw"]:
return {"vdb": {"memories": self.rows, "total": self.total}}
return {
"scene": "all",
"memories": self.rows,
"total": self.total,
"totals": {"chat": len(self.rows), "working": 0, "coding": 0},
"errors": {},
}
def memory_stats(self, **kwargs):
raise AssertionError("home projection must use one scene=all snapshot")
def test_raw_excerpt_keeps_only_user_query_and_truncates_by_codepoint() -> None:
text = (
"[user]: <system_reminder>ignore this</system_reminder>\n"
"<user_info>private host data</user_info>\n"
"<user_query>短句 https://example.com/a /tmp/x " + "🙂" * 220 + "</user_query>\n"
"[assistant]: secret answer"
)
excerpt, truncated = raw_user_excerpt(text)
assert excerpt.startswith("短句 https://example.com/a /tmp/x ")
assert "ignore" not in excerpt and "private" not in excerpt and "secret" not in excerpt
assert len(excerpt) == 200
assert truncated is True
def test_raw_excerpt_fails_closed_on_nested_host_wrappers() -> None:
assert raw_user_excerpt(
"[user]: <system_reminder><user_query>keep me</user_query></system_reminder>"
) == ("", False)
@pytest.mark.parametrize(
"wrapper",
[
"system_reminder",
"system-reminder",
"user_info",
"attached_files",
"image_local_path",
"agent_transcripts",
"rules",
"additional_data",
"working_memory_reminder",
],
)
def test_raw_excerpt_uses_shared_envelope_parser_for_host_context(wrapper) -> None:
excerpt, truncated = raw_user_excerpt(
f"[user]: <{wrapper}>private</{wrapper}><user_query>keep me</user_query>"
)
assert (excerpt, truncated) == ("keep me", False)
@pytest.mark.parametrize(
"text",
[
"[user]: <system_reminder>unclosed <user_query>do not guess</user_query>",
"[user]: <user_query>unclosed",
"[user]: <rules><rules>nested</rules><user_query>ambiguous</user_query></rules>",
],
)
def test_raw_excerpt_fails_closed_on_ambiguous_envelopes(text) -> None:
assert raw_user_excerpt(text) == ("", False)
def test_raw_projection_uses_shadow_rows_and_k10_without_leaking_payload() -> None:
sdk = ProjectionSdk(
[
raw_row("r-1", "[assistant]: no user here", status="shadow"),
raw_row("r-2", "[user]: deploy Friday", status="active"),
],
total=42,
)
result = asyncio.run(read_home_projection(sdk, user_id="local", mode="lite"))
assert result.visible_count == 42
assert result.latest is not None and result.latest.type == "raw"
assert result.latest.raw_id == "r-2"
assert result.latest.excerpt == "deploy Friday"
assert result.latest.time_basis == "conversation"
assert result.display_state == "excerpt"
assert sdk.calls == [
{
"user_id": "local",
"layers": ["l1_raw"],
"statuses": ["active", "shadow"],
"limit": HOME_PROJECTION_CANDIDATES,
"offset": 0,
"order": "desc",
}
]
assert set(result.latest.model_dump()) == {
"type",
"raw_id",
"session_id",
"display_state",
"time",
"time_basis",
"excerpt",
"truncated",
"source_id",
}
serialized = result.model_dump(mode="json")
assert serialized["projection_version"] == 1
assert serialized["latest"]["time"].endswith("Z")
assert "content" not in serialized["latest"]
def test_raw_projection_reports_no_user_excerpt_when_k_has_none() -> None:
sdk = ProjectionSdk([raw_row("r-1", "[assistant]: answer")])
result = asyncio.run(read_home_projection(sdk, user_id="local", mode="lite"))
assert result.latest is not None and result.latest.type == "raw"
assert result.latest.raw_id == "r-1"
assert result.latest.display_state == "no_user_excerpt"
assert result.latest.excerpt == ""
@pytest.mark.parametrize(
("mode", "graph", "evolve"),
[("pro", False, False), ("ultra", True, True)],
)
def test_memory_projection_covers_pro_and_ultra(mode, graph, evolve) -> None:
sdk = ProjectionSdk([chat_row()], total=9)
result = asyncio.run(read_home_projection(sdk, user_id="local", mode=mode))
assert result.visible_count == 9
assert result.display_state == "excerpt"
assert result.capabilities.content_plane == "memory"
assert result.capabilities.graph_enabled is graph
assert result.capabilities.evolve_available is evolve
assert result.latest is not None and result.latest.type == "memory"
assert result.latest.memory_id == "m-1"
assert result.latest.time_basis == "update"
assert result.latest.time.isoformat() == "2027-01-15T08:00:00+00:00"
def test_chat_projection_prefers_gmt_modified_when_the_sdk_supplies_it() -> None:
row = {**chat_row(), "gmt_modified": 1_810_000_000}
result = asyncio.run(read_home_projection(ProjectionSdk([row]), user_id="local", mode="pro"))
assert result.latest is not None
assert result.latest.time_basis == "update"
assert result.latest.time.timestamp() == 1_810_000_000
def test_memory_projection_uses_productivity_updated_at() -> None:
row = {
"kind": "experience",
"experience_id": "e-1",
"task": "Release",
"solution": "Run tests",
"scene": "working",
"status": "active",
"user_id": "local",
"agent_id": "all-apps",
"source": "manual",
"memory_at": 1_600_000_000,
"created_at": 1_700_000_000,
"updated_at": 1_710_000_000,
}
result = asyncio.run(read_home_projection(ProjectionSdk([row]), user_id="local", mode="pro"))
assert result.latest is not None and result.latest.type == "memory"
assert (result.latest.scene, result.latest.kind, result.latest.variant) == (
"working",
"experience",
"working",
)
assert result.latest.time_basis == "update"
assert result.latest.time.isoformat() == "2024-03-09T16:00:00+00:00"
@pytest.mark.parametrize(
"row",
[
{**chat_row(), "updated_at": None},
{
"kind": "experience",
"experience_id": "e-1",
"task": "Release",
"solution": "Run tests",
"scene": "coding",
"status": "active",
"user_id": "local",
"agent_id": "all-apps",
"source": "manual",
"created_at": 1_700_000_000,
"updated_at": None,
},
],
)
def test_memory_projection_sort_time_falls_back_to_scene_creation(row) -> None:
result = asyncio.run(read_home_projection(ProjectionSdk([row]), user_id="local", mode="pro"))
assert result.latest is not None
assert result.latest.time_basis == "write"
expected = row.get("gmt_created") or row.get("created_at")
assert result.latest.time.timestamp() == expected
def test_fact_is_not_a_home_scene_all_item() -> None:
row = {
"kind": "fact",
"fact_id": "f-1",
"statement": "Uses pytest",
"scene": "coding",
"status": "active",
"user_id": "local",
"agent_id": "all-apps",
"source": "manual",
"created_at": 1_700_000_000,
}
result = asyncio.run(read_home_projection(ProjectionSdk([row]), user_id="local", mode="pro"))
assert result.visible_count == 1
assert result.latest is None
assert result.display_state == "no_displayable_memory"
def test_invalid_latest_uses_next_parseable_candidate_from_same_snapshot() -> None:
sdk = ProjectionSdk(
[
{"memory_id": "broken", "content": "", "scene": "chat", "kind": "chat"},
chat_row("m-2"),
],
total=2,
)
result = asyncio.run(read_home_projection(sdk, user_id="local", mode="pro"))
assert result.visible_count == 2
assert result.latest is not None and result.latest.memory_id == "m-2"
assert len(sdk.calls) == 1
def test_scene_all_rejects_boolean_total() -> None:
sdk = ProjectionSdk([chat_row()])
sdk.total = True # type: ignore[assignment]
with pytest.raises(HySdkReadError):
asyncio.run(read_home_projection(sdk, user_id="local", mode="pro"))
def test_projection_read_failure_is_not_converted_to_an_empty_projection() -> None:
class Broken:
def list_memories(self, **kwargs):
raise OSError("down")
with pytest.raises(OSError, match="down"):
asyncio.run(read_home_projection(Broken(), user_id="local", mode="lite"))
def test_projection_refuses_a_client_built_for_another_mode() -> None:
sdk = ProjectionSdk([chat_row()])
sdk.mode = "lite"
with pytest.raises(RuntimeError, match="home_projection_mode_mismatch"):
asyncio.run(read_home_projection(sdk, user_id="local", mode="pro"))
def test_home_projection_route_maps_whole_read_failure_to_503(tmp_path, monkeypatch) -> None:
class Broken:
def list_memories(self, **kwargs):
raise OSError("down")
class Handle:
async def client(self):
return Broken()
app = create_app(
Settings(
_env_file=None,
data_dir=tmp_path / "data",
source_home=tmp_path,
api_token=SecretStr("test"),
imports_enabled=False,
automatic_memory_processing=False,
live_sync_enabled=False,
),
source_catalog={},
)
monkeypatch.setattr("one_memory.api._sdk_client_handle", lambda _app: Handle())
with TestClient(app, headers={"Authorization": "Bearer test"}) as client:
response = client.get("/api/v2/home-projection")
assert response.status_code == 503
assert response.json() == {"detail": "home_projection_read_failed"}
def test_home_projection_reads_mode_once_and_matches_the_client(tmp_path, monkeypatch) -> None:
sdk = ProjectionSdk([chat_row()])
sdk.mode = "pro"
class Handle:
async def client(self):
return sdk
app = create_app(
Settings(
_env_file=None,
data_dir=tmp_path / "data",
source_home=tmp_path,
api_token=SecretStr("test"),
imports_enabled=False,
automatic_memory_processing=False,
live_sync_enabled=False,
),
source_catalog={},
)
reads = 0
def mode(_app):
nonlocal reads
reads += 1
return "pro"
with TestClient(app, headers={"Authorization": "Bearer test"}) as client:
monkeypatch.setattr("one_memory.api._memory_mode", mode)
monkeypatch.setattr("one_memory.api._sdk_client_handle", lambda _app: Handle())
response = client.get("/api/v2/home-projection")
assert response.status_code == 200, response.text
assert response.json()["effective_mode"] == "pro"
assert reads == 1
def test_project_raw_latest_uses_import_time_without_conversation_time() -> None:
from one_memory.hy_sdk_types import HySdkRawRecord
record = HySdkRawRecord.from_sdk(
{
**raw_row("r-1", "[user]: hello", memory_at=None),
"raw_id": "r-1",
}
)
latest = project_raw_latest((record,))
assert latest is not None
assert latest.time_basis == "import"
+1 -1
View File
@@ -298,7 +298,7 @@ def test_hook_job_completes_after_file_identity_adoption(tmp_path) -> None:
assert current is not None
assert ingest_journal.matches_current(connection, current)
connection.rollback()
await before_hy_invocation()
await before_hy_invocation(True)
return SdkWriteReceipt()
drain = DurableWriteDrain(queue, execute=lambda _job: None)
@@ -524,6 +524,11 @@ def test_features_api_get_put_reset_and_status(tmp_path, monkeypatch) -> None:
"episode_recall": True,
}
monkeypatch.setattr(
app.state.model_profile_store,
"active_identity",
lambda: ("active-profile", 1),
)
await client.put("/api/v1/settings/memory-mode", json={"mode": "ultra"})
await app.state.memory_graph_backfill.wait()
ultra = _by_name((await client.get("/api/v1/settings/memory-features")).json())
@@ -41,6 +41,8 @@ from one_memory.memory_evolution import (
EvolveScheduler,
EvolveService,
EvolveStateStore,
GraphBuildState,
GraphBuildUser,
MemoryGraphBackfill,
evolve_due,
)
@@ -58,8 +60,10 @@ def test_mode_defaults_to_pro_and_the_env_still_forces_ultra(tmp_path, monkeypat
monkeypatch.delenv("ONE_MEMORY_GRAPH_STORE", raising=False)
store = ConfigStore(tmp_path)
assert store.memory_mode() == "pro"
assert store.memory_mode_explicitly_configured() is False
assert effective_memory_mode(store.memory_mode()) == "pro"
store.set_memory_mode("lite")
assert store.memory_mode_explicitly_configured() is True
assert ConfigStore(tmp_path).memory_mode() == "lite"
assert json.loads((tmp_path / "config.json").read_text())["memory_mode"] == "lite"
monkeypatch.setenv("ONE_MEMORY_GRAPH_STORE", "kuzu")
@@ -70,6 +74,21 @@ def test_mode_defaults_to_pro_and_the_env_still_forces_ultra(tmp_path, monkeypat
store.set_memory_mode("max") # type: ignore[arg-type]
def test_old_config_defaults_to_implicit_pro_until_pro_is_explicitly_set(tmp_path) -> None:
(tmp_path / "config.json").write_text(
json.dumps({"schema_version": 1, "sources": {}}), encoding="utf-8"
)
store = ConfigStore(tmp_path)
assert store.memory_mode() == "pro"
assert store.memory_mode_explicitly_configured() is False
store.set_memory_mode("pro")
assert store.memory_mode_explicitly_configured() is True
assert json.loads((tmp_path / "config.json").read_text())["memory_mode"] == "pro"
assert ConfigStore(tmp_path).memory_mode_explicitly_configured() is True
def test_an_untouched_install_writes_no_new_settings(tmp_path) -> None:
store = ConfigStore(tmp_path)
store.set_sync("cursor", True)
@@ -158,6 +177,37 @@ def test_the_read_handle_rebuilds_on_a_mode_or_profile_change(monkeypatch, tmp_p
asyncio.run(scenario())
def test_read_handle_can_bind_one_caller_mode_snapshot(monkeypatch, tmp_path) -> None:
from one_memory import sdk_read_handle
callback_reads = 0
def current_mode() -> str:
nonlocal callback_reads
callback_reads += 1
return "pro"
monkeypatch.setattr(
sdk_read_handle,
"SdkClientHandle",
lambda profile, **kwargs: _FakeHandle(kwargs.get("mode") or "pro", profile.profile_id),
)
reads = sdk_read_handle.ActiveProfileReadHandle(
profiles=_Profiles(identity=None), # type: ignore[arg-type]
secrets=None, # type: ignore[arg-type]
data_dir=tmp_path,
memory_mode=current_mode,
)
async def scenario():
client = await reads.client_for_mode("lite")
assert client.mode == "lite"
await reads.close()
asyncio.run(scenario())
assert callback_reads == 0
class _FakeHandle:
def __init__(self, mode: str, profile_id: str = "p") -> None:
self.mode = mode
@@ -288,7 +338,7 @@ class _SdkHandle:
return None
async def _noop() -> None:
async def _noop(_requires_generation: bool) -> None:
return None
@@ -311,14 +361,14 @@ def test_a_note_in_lite_is_stored_as_raw_and_never_calls_add_extracted() -> None
]
def test_a_lite_write_runs_through_the_coordinator_without_a_model(monkeypatch) -> None:
def test_a_lite_write_ignores_the_model_sedimentation_pause(monkeypatch) -> None:
sdk = _WritingSdk()
coordinator = sdk_write_processor.SdkWriteCoordinator(
profiles=_Profiles(identity=None), # type: ignore[arg-type]
secrets=None, # type: ignore[arg-type]
data_dir=None,
transport_factory=lambda _profile: None,
sedimentation_enabled=lambda: True,
sedimentation_enabled=lambda: False,
memory_mode=lambda: "lite",
)
monkeypatch.setattr(
@@ -335,6 +385,37 @@ def test_a_lite_write_runs_through_the_coordinator_without_a_model(monkeypatch)
assert [name for name, _ in sdk.calls] == ["add"]
def test_a_lite_write_ignores_the_model_sedimentation_switch(monkeypatch) -> None:
sdk = _WritingSdk()
coordinator = sdk_write_processor.SdkWriteCoordinator(
profiles=_Profiles(identity=None), # type: ignore[arg-type]
secrets=None, # type: ignore[arg-type]
data_dir=None,
transport_factory=lambda _profile: None,
sedimentation_enabled=lambda: False,
memory_mode=lambda: "lite",
)
monkeypatch.setattr(
sdk_write_processor, "SdkClientHandle", lambda profile, **kwargs: _SdkHandle(sdk, "lite")
)
admitted = 0
async def admit(_requires_generation: bool) -> None:
nonlocal admitted
admitted += 1
receipt = asyncio.run(
coordinator.process_sdk_job(
(_record("still stored as raw", extracted=False),),
request_id="r-off-lite",
before_hy_invocation=admit,
)
)
assert receipt.raw_ids == ("raw-1",)
assert admitted == 1
assert [name for name, _ in sdk.calls] == ["add"]
def test_no_raw_is_ever_deleted_in_any_mode() -> None:
"""Owner decision: MemHub keeps every raw; there is no retention window."""
from one_memory import sdk_memory_runtime
@@ -624,6 +705,38 @@ def test_the_scheduler_runs_each_due_user_once_in_ultra_only(tmp_path, monkeypat
assert state.user("wife").last_result["trigger"] == "scheduled"
def test_scheduled_evolve_obeys_the_shared_lifecycle_gate(tmp_path, monkeypatch) -> None:
sdk = _EvolveSdk(continuing=0)
state = EvolveStateStore(tmp_path)
state.anchor(_at(1, 12))
open_gate = [False]
service = EvolveService(
coordinator=_Coordinator(sdk, monkeypatch),
state=state,
memory_mode=lambda: "ultra",
now=lambda: _at(2, 3, 2),
)
async def users():
return ("local",)
async def scenario():
scheduler = EvolveScheduler(
service=service,
schedule=EvolveSchedule,
users_with_memories=users,
lifecycle_lock=asyncio.Lock(),
lifecycle_open=lambda: open_gate[0],
now=lambda: _at(2, 3, 2),
)
assert await scheduler.tick() == ()
assert sdk.calls == []
open_gate[0] = True
assert await scheduler.tick() == ("local",)
asyncio.run(scenario())
def test_the_graph_backfill_runs_once_per_user(tmp_path, monkeypatch) -> None:
sdk = _EvolveSdk()
state = EvolveStateStore(tmp_path)
@@ -643,6 +756,63 @@ def test_the_graph_backfill_runs_once_per_user(tmp_path, monkeypatch) -> None:
}
def test_an_interrupted_graph_backfill_resumes_only_unfinished_users(tmp_path, monkeypatch) -> None:
sdk = _EvolveSdk()
state = EvolveStateStore(tmp_path)
state.set_graph_build(
GraphBuildState(
state="running",
started_at=_at(1, 3),
users={
"local": GraphBuildUser(state="succeeded", memories_scanned=4, nodes=4),
"wife": GraphBuildUser(state="running"),
},
)
)
backfill = MemoryGraphBackfill(coordinator=_Coordinator(sdk, monkeypatch), state=state)
async def scenario():
resumed = backfill.recover(["local", "wife"])
assert resumed.state == "running"
assert resumed.users["local"].state == "succeeded"
assert resumed.users["wife"].state == "pending"
await backfill.wait()
asyncio.run(scenario())
assert sdk.graph_builds == ["wife"]
assert state.graph_build().state == "succeeded"
def test_graph_backfill_waits_for_embedding_maintenance_to_finish(tmp_path, monkeypatch) -> None:
sdk = _EvolveSdk()
state = EvolveStateStore(tmp_path)
gate = [False]
checked = asyncio.Event()
def lifecycle_open() -> bool:
checked.set()
return gate[0]
backfill = MemoryGraphBackfill(
coordinator=_Coordinator(sdk, monkeypatch),
state=state,
lifecycle_open=lifecycle_open,
)
async def scenario():
backfill.start(["local"])
await checked.wait()
assert sdk.graph_builds == []
assert state.graph_build().state == "running"
assert state.graph_build().users["local"].state == "pending"
gate[0] = True
await backfill.wait()
asyncio.run(scenario())
assert sdk.graph_builds == ["local"]
assert state.graph_build().state == "succeeded"
# --- the HTTP surface -----------------------------------------------------------
@@ -701,23 +871,38 @@ def test_mode_api_get_put_and_its_effects(tmp_path, monkeypatch) -> None:
async def scenario(app, sdk, client):
current = (await client.get("/api/v1/settings/memory-mode")).json()
assert current["mode"] == current["configured_mode"] == "pro"
assert current["explicitly_configured"] is False
assert current["forced_by_env"] is False
assert app.state.sdk_write_coordinator.memory_mode() == "pro"
assert (
await client.put("/api/v1/sedimentation", json={"enabled": False})
).status_code == 200
from one_memory.api import _sedimentation_claim_enabled
app.state.history_import_gate_released = True
assert not _sedimentation_claim_enabled(app, "one-memory-manual")
app.state.config_store.set_sedimentation_enabled(False)
lite = await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"})
assert lite.status_code == 200, lite.text
body = lite.json()
assert body["mode"] == "lite" and body["previous_mode"] == "pro"
assert body["explicitly_configured"] is True
assert body["effects"] == ["higher_mode_memories_hidden"]
resume = getattr(app.state, "sedimentation_resume", None)
assert resume is not None
await asyncio.wait_for(asyncio.shield(resume), 5)
assert app.state.sdk_write_coordinator.memory_mode() == "lite"
assert _sdk_client_handle(app).memory_mode() == "lite"
# lite claims queued work without a generation model.
from one_memory.api import _sedimentation_claim_enabled
assert app.state.model_profile_store.active_identity() is None
app.state.history_import_gate_released = True
assert _sedimentation_claim_enabled(app, "one-memory-manual")
monkeypatch.setattr(
app.state.model_profile_store,
"active_identity",
lambda: ("active-profile", 1),
)
ultra = await client.put("/api/v1/settings/memory-mode", json={"mode": "ultra"})
assert ultra.status_code == 200, ultra.text
assert "existing_memories_added_to_graph" in ultra.json()["effects"]
@@ -741,6 +926,173 @@ def test_mode_api_get_put_and_its_effects(tmp_path, monkeypatch) -> None:
run_app(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_mode_plan_and_put_share_validation_and_effects(tmp_path, monkeypatch) -> None:
async def scenario(app, _sdk, client):
plan = await client.post(
"/api/v1/settings/memory-mode/plan",
json={"mode": "lite"},
)
assert plan.status_code == 200
body = plan.json()
assert body == {
"current": "pro",
"target": "lite",
"effective_target": "lite",
"capabilities": {
"content_plane": "raw",
"generation_required": False,
"graph_enabled": False,
"evolve_available": False,
},
"effects": ["higher_mode_memories_hidden"],
"warnings": [],
"blockers": [],
}
changed = await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"})
assert changed.status_code == 200
assert changed.json()["effects"] == body["effects"]
missing_model = (
await client.post(
"/api/v1/settings/memory-mode/plan",
json={"mode": "pro"},
)
).json()
assert missing_model["blockers"] == ["active_model_required"]
assert missing_model["effects"] == []
refused = await client.put("/api/v1/settings/memory-mode", json={"mode": "pro"})
assert refused.status_code == 409
assert refused.json()["detail"] == missing_model["blockers"][0]
monkeypatch.setenv("ONE_MEMORY_GRAPH_STORE", "kuzu")
forced = (
await client.post(
"/api/v1/settings/memory-mode/plan",
json={"mode": "lite"},
)
).json()
assert forced["effective_target"] == "ultra"
assert forced["warnings"] == ["forced_by_env", "effective_no_change"]
run_app(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_mode_plan_reports_all_blockers_without_planned_effects(tmp_path, monkeypatch) -> None:
async def scenario(app, _sdk, client):
changed = await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"})
assert changed.status_code == 200
switch = app.state.embedding_switch
switch._scan_hold = True
hold = asyncio.Event()
task = asyncio.create_task(hold.wait())
app.state.memory_graph_backfill._task = task
monkeypatch.setattr("one_memory.api.importlib.util.find_spec", lambda _name: None)
try:
plan = (
await client.post(
"/api/v1/settings/memory-mode/plan",
json={"mode": "ultra"},
)
).json()
assert plan["blockers"] == [
"embedding_maintenance_in_progress",
"active_model_required",
"memory_lifecycle_busy",
"graph_store_unavailable",
]
assert plan["effects"] == []
finally:
switch._scan_hold = False
task.cancel()
await asyncio.gather(task, return_exceptions=True)
app.state.memory_graph_backfill._task = None
run_app(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_lite_startup_readmits_jobs_while_model_sedimentation_is_off(tmp_path, monkeypatch) -> None:
store = ConfigStore(tmp_path / "data")
store.set_memory_mode("lite")
store.set_sedimentation_enabled(False)
resumed = asyncio.Event()
original = SdkMemoryRuntime.resume_allowed
async def resume_allowed(runtime):
await original(runtime)
resumed.set()
monkeypatch.setattr(SdkMemoryRuntime, "resume_allowed", resume_allowed)
async def scenario(app, _sdk, _client):
await asyncio.wait_for(resumed.wait(), 5)
assert app.state.config_store.sedimentation_enabled() is False
assert app.state.config_store.memory_mode() == "lite"
run_app(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_embedding_maintenance_blocks_mode_put_and_evolve_but_not_mode_get(
tmp_path, monkeypatch
) -> None:
async def scenario(app, _sdk, client):
switch = app.state.embedding_switch
switch._scan_hold = True
current = await client.get("/api/v1/settings/memory-mode")
assert current.status_code == 200
assert current.json()["explicitly_configured"] is False
for response in (
await client.put("/api/v1/settings/memory-mode", json={"mode": "pro"}),
await client.post("/api/v2/evolve", json={}),
):
assert response.status_code == 409
assert response.json()["detail"] == "embedding_maintenance_in_progress"
assert app.state.config_store.memory_mode_explicitly_configured() is False
switch._scan_hold = False
switch._open_cache = True
assert (await client.get("/api/v1/settings/memory-mode")).status_code == 200
for response in (
await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"}),
await client.post("/api/v2/evolve", json={}),
):
assert response.status_code == 409
assert response.json()["detail"] == "embedding_maintenance_in_progress"
switch._open_cache = False
explicitly_pro = await client.put("/api/v1/settings/memory-mode", json={"mode": "pro"})
assert explicitly_pro.status_code == 200
assert explicitly_pro.json()["explicitly_configured"] is True
assert explicitly_pro.json()["effects"] == []
run_app(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_graph_backfill_blocks_mode_change_and_manual_evolve(tmp_path, monkeypatch) -> None:
async def scenario(app, _sdk, client):
hold = asyncio.Event()
task = asyncio.create_task(hold.wait())
app.state.memory_graph_backfill._task = task
try:
for response in (
await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"}),
await client.post("/api/v2/evolve", json={}),
):
assert response.status_code == 409
assert response.json()["detail"] == "memory_lifecycle_busy"
finally:
task.cancel()
await asyncio.gather(task, return_exceptions=True)
app.state.memory_graph_backfill._task = None
run_app(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_evolve_api_is_ultra_only_async_and_scoped_per_user(tmp_path, monkeypatch) -> None:
async def scenario(app, sdk, client):
@@ -748,7 +1100,13 @@ def test_evolve_api_is_ultra_only_async_and_scoped_per_user(tmp_path, monkeypatc
assert refused.status_code == 409
assert refused.json()["detail"] == "memory_mode_not_ultra"
await client.put("/api/v1/settings/memory-mode", json={"mode": "ultra"})
monkeypatch.setattr(
app.state.model_profile_store,
"active_identity",
lambda: ("active-profile", 1),
)
changed = await client.put("/api/v1/settings/memory-mode", json={"mode": "ultra"})
assert changed.status_code == 200, changed.text
await app.state.memory_graph_backfill.wait()
started = await client.post(
"/api/v2/evolve", json={"user_id": "wife", "agent_id": "all-apps", "max_batches": 2}
@@ -865,3 +1223,87 @@ def test_a_job_written_in_lite_stays_done_and_a_pending_job_takes_the_new_mode(
await coordinator.close()
run_app(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_paused_model_still_writes_a_lite_job_through_the_durable_boundary(
tmp_path, monkeypatch
) -> None:
from one_memory.api import _admit_pipeline_invocation
from one_memory.memory_server.durable_queue import DurableWriteDrain, execute_job
seen: list[str] = []
class Sdk:
mode = "lite"
def add(self, messages, **kwargs):
seen.append("add")
return {"success": True, "raw_ids": ["raw-lite"]}
def add_extracted(self, contents, **kwargs):
seen.append("add_extracted")
raise AssertionError("lite must never call add_extracted")
monkeypatch.setattr(
sdk_write_processor,
"SdkClientHandle",
lambda profile, **kwargs: _SdkHandle(Sdk(), "lite"),
)
coordinator = sdk_write_processor.SdkWriteCoordinator(
profiles=_Profiles(identity=None), # type: ignore[arg-type]
secrets=None, # type: ignore[arg-type]
data_dir=None,
transport_factory=lambda _profile: None,
sedimentation_enabled=lambda: False,
memory_mode=lambda: "lite",
)
async def never(_job_id):
return None
async def scenario(app, _sdk, client):
app.state.memory_write_drain.stop()
if app.state.memory_write_drain_task is not None:
await app.state.memory_write_drain_task
app.state.memory_write_drain_task = None
await client.put("/api/v1/sedimentation", json={"enabled": False})
await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"})
app.state.history_import_gate_released = True
written = await client.post(
"/api/v2/memory-writes",
json={
"request_id": "paused-lite-turn",
"session_id": "paused-lite-session",
"source_id": "one-memory-manual",
"messages": [
{"role": "user", "content": "保留原始会话"},
{"role": "assistant", "content": "已保留"},
],
},
)
assert written.status_code == 202
queue = app.state.memory_write_queue
drain = DurableWriteDrain(queue, execute=never, lease=timedelta(seconds=30))
[job_id] = drain.claim(limit=1)
await execute_job(
job_id,
queue=queue,
supervisor=coordinator,
evidence=None, # type: ignore[arg-type]
admit_hy_invocation=lambda source_id, requires_generation, mark: (
_admit_pipeline_invocation(
app,
source_id,
requires_generation,
mark,
)
),
)
current = queue.get("paused-lite-turn")
assert current is not None and current.state == "completed"
assert current.sdk_raw_ids == ("raw-lite",)
assert seen == ["add"]
await coordinator.close()
run_app(tmp_path, monkeypatch, scenario)
@@ -832,7 +832,7 @@ class _Recorder:
async def process_sdk_job(self, records, **_kwargs):
self.seen.append(tuple(record.evidence_id for record in records))
if self.invoke_hy:
await _kwargs["before_hy_invocation"]()
await _kwargs["before_hy_invocation"](True)
if self.error is not None:
raise self.error
return type("Receipt", (), {"raw_ids": (), "memory_ids": ()})()
@@ -844,10 +844,10 @@ class _TwoInvocationRecorder:
self.invocations = 0
async def process_sdk_job(self, _records, **kwargs):
await kwargs["before_hy_invocation"]()
await kwargs["before_hy_invocation"](True)
self.invocations += 1
self.pause_after_first()
await kwargs["before_hy_invocation"]()
await kwargs["before_hy_invocation"](True)
self.invocations += 1
return type("Receipt", (), {"raw_ids": (), "memory_ids": ()})()
@@ -916,7 +916,7 @@ def test_execute_job_persists_sdk_ids_returned_after_the_single_add(tmp_path) ->
@classmethod
async def process_sdk_job(cls, _records, **kwargs):
cls.calls += 1
await kwargs["before_hy_invocation"]()
await kwargs["before_hy_invocation"](True)
return type(
"Result",
(),
@@ -1045,7 +1045,7 @@ def test_sdk_write_processor_calls_add_once_with_public_scope_and_receipt() -> N
)
async def run() -> SdkWriteReceipt:
async def before() -> None:
async def before(_requires_generation: bool) -> None:
client.events.append("before")
return await processor.process_sdk_job(
@@ -1105,7 +1105,7 @@ def test_sdk_write_processor_uses_add_extracted_for_memory_content_and_additions
processor = SdkWriteProcessor(_SdkPublicHandle(client)) # type: ignore[arg-type]
async def run() -> SdkWriteReceipt:
async def before() -> None:
async def before(_requires_generation: bool) -> None:
client.events.append("before")
return await processor.process_sdk_job(
@@ -1260,7 +1260,7 @@ def test_a_profile_change_does_not_close_the_client_of_a_write_in_flight(monkeyp
async def process(self, records, *, request_id, before_hy_invocation):
client = await self._client_handle.client()
await before_hy_invocation()
await before_hy_invocation(getattr(client, "mode", None) != "lite")
seen.append((client.profile_id, client.closed))
if client.profile_id == "profile-a":
await writing.wait()
@@ -1270,7 +1270,7 @@ def test_a_profile_change_does_not_close_the_client_of_a_write_in_flight(monkeyp
monkeypatch.setattr(sdk_write_processor.SdkWriteProcessor, "process_sdk_job", process)
async def noop():
async def noop(_requires_generation: bool):
return None
async def scenario():
@@ -1728,7 +1728,7 @@ def test_a_failed_job_logs_where_it_failed_but_never_the_message(tmp_path, caplo
class _Failing:
async def process_sdk_job(self, _records, **kwargs):
await kwargs["before_hy_invocation"]()
await kwargs["before_hy_invocation"](True)
try:
closed_store(type("Client", (), {"private_store_name": None})())
except AttributeError as error:
@@ -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.
@@ -4758,7 +4823,7 @@ def test_sedimentation_pause_releases_claim_without_spending_an_attempt(tmp_path
drain = DurableWriteDrain(queue, execute=_never_execute, owner="pause-owner")
assert drain.claim(limit=1) == (record.job_id,)
async def refuse_before_hy(_source_id, _mark_invoked):
async def refuse_before_hy(_source_id, _requires_generation, _mark_invoked):
raise RuntimeError("model_sedimentation_disabled")
asyncio.run(
@@ -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)
@@ -17,6 +17,7 @@ from one_memory.memory_server.migration_runner import (
InMemoryMigrationStore,
MigrationResult,
MigrationSchemaMismatchError,
SqliteMigrationStore,
ledger_sql,
migrate,
)
@@ -138,6 +139,76 @@ def test_sdk_write_receipts_are_owned_by_migration_56() -> None:
assert "CREATE TABLE IF NOT EXISTS memory_task_sdk_receipts" in ddl
def test_onboarding_progress_migrates_old_sqlite_columns_in_append_order(tmp_path) -> None:
path = tmp_path / "old-onboarding.sqlite3"
with sqlite3.connect(path) as database:
database.execute(
"""
CREATE TABLE onboarding_state (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
status TEXT NOT NULL,
selected_source_ids_json TEXT NOT NULL,
started_at TEXT,
completed_at TEXT,
updated_at TEXT NOT NULL
)
"""
)
database.execute(
"INSERT INTO onboarding_state VALUES "
"(1,'importing','[\"cursor\"]',NULL,NULL,'2026-11-04T00:00:00+00:00')"
)
migration = next(item for item in MIGRATIONS if item.version == 62)
result = asyncio.run(migrate(SqliteMigrationStore(path), (migration,)))
assert result.applied == (62,)
with sqlite3.connect(path) as database:
columns = [str(row[1]) for row in database.execute("PRAGMA table_info(onboarding_state)")]
row = database.execute(
"SELECT mode_intent, setup_step, model_profile_id FROM onboarding_state"
).fetchone()
assert columns == [
"singleton",
"status",
"selected_source_ids_json",
"started_at",
"completed_at",
"updated_at",
"mode_intent",
"setup_step",
"model_profile_id",
]
assert row == (None, None, None)
def test_onboarding_progress_mysql_retries_each_duplicate_column() -> None:
class DuplicateColumnError(Exception):
pass
class Cursor:
dialect = "mysql"
def __init__(self) -> None:
self.attempted: list[str] = []
self.applied: list[str] = []
async def execute(self, statement: str) -> None:
self.attempted.append(statement)
if "ADD COLUMN mode_intent" in statement or "ADD COLUMN setup_step" in statement:
raise DuplicateColumnError(1060, "duplicate column")
self.applied.append(statement)
cursor = Cursor()
migration = next(item for item in MIGRATIONS if item.version == 62)
asyncio.run(migration.apply(cursor))
assert len(cursor.attempted) == 3
assert len(cursor.applied) == 1
assert "model_profile_id" in cursor.applied[0]
def test_sdk_matter_references_are_owned_by_migration_57() -> None:
from one_memory.memory_server.migrations import MIGRATIONS, sdk_matter_references_ddl
@@ -259,7 +259,7 @@ def test_old_branch_job_executes_its_frozen_revision_after_harness_rewind(tmp_pa
):
assert request_id == old_job.request_id
captured.extend(records)
await before_hy_invocation()
await before_hy_invocation(True)
return SimpleNamespace(raw_ids=(), memory_ids=())
asyncio.run(
+269 -1
View File
@@ -14,6 +14,13 @@ from pydantic import SecretStr
from one_memory.api import create_app
from one_memory.config import Settings
from one_memory.imports.models import (
EvidenceInput,
EvidenceOrigin,
OriginMetadata,
WrittenBy,
materialize_write_input,
)
from one_memory.memory_server.contracts import BatchIdentity, WriteRequest
from one_memory.memory_server.durable_queue import (
FAILED,
@@ -29,6 +36,7 @@ from one_memory.model_backoff import (
ModelBackoffRefusal,
backoff_seconds,
)
from one_memory.sdk_write_processor import SdkWriteProcessor, sdk_write_failure_code
from one_memory.secrets import InMemorySecretStore
CHAT = "https://models.example.com/v1/chat/completions"
@@ -176,6 +184,17 @@ def test_a_working_embedding_endpoint_does_not_clear_a_broken_chat_model() -> No
assert backoff.paused()
def test_claim_allowance_can_pace_embedding_without_pausing_lite_for_chat() -> None:
backoff = ModelBackoff(clock=_Clock())
backoff.record_failure("model_not_found", "p:1", "chat")
assert backoff.allowance(kind="embedding") is None
assert backoff.allowance(kind="chat") == 0
backoff.reset()
backoff.record_failure("embedding_service_failed", "p:1", "embedding")
assert backoff.allowance(kind="embedding") == 0
def test_an_unreachable_provider_is_paused_like_a_refusing_one() -> None:
clock = _Clock()
backoff = ModelBackoff(clock=clock)
@@ -302,7 +321,7 @@ class _FakeSdk:
self.fail_with: str | None = None
async def process_sdk_job(self, records, *, request_id, before_hy_invocation):
await before_hy_invocation()
await before_hy_invocation(True)
self.request_ids.append(request_id)
if request_id in self.outcomes:
if self.outcomes[request_id] == "failed":
@@ -605,3 +624,252 @@ def test_the_manual_retry_endpoint_is_gone(tmp_path) -> None:
return response.status_code
assert asyncio.run(scenario()) in {404, 405}
# --- hy-memory 2.0.13: a model failure is `success: false`, never a silent success ---
class _Sdk2013Client:
"""`HyMemoryClient.add` as hy-memory 2.0.13 answers it.
Each write makes one model call through MemHub's transport, so the
breaker sees what the provider answered. The SDK turns a failed or
unusable model reply into `success: false` with its stable codes; the
shape of those results is the SDK's own (`_mark_reconcile_failure`,
`CodingWriter`).
"""
def __init__(self, transport: BackedOffModelTransport) -> None:
self.transport = transport
self.reply = "[]"
self.messages: list[list[dict[str, str]]] = []
def add(self, messages, **_kwargs):
self.messages.append(messages)
coding = any(message["role"] == "tool" for message in messages)
try:
response = asyncio.run(self.transport.handle_async_request(httpx.Request("POST", CHAT)))
except httpx.TransportError:
return self._failed(coding, "LLM_UNAVAILABLE", "LLM_ERROR")
if response.status_code >= 400:
return self._failed(coding, "LLM_UNAVAILABLE", "LLM_ERROR")
if not self.reply:
return self._failed(coding, "EMPTY_RESPONSE", "EMPTY_RESPONSE")
if not self.reply.startswith("["):
return self._failed(coding, "JSON_PARSE_FAILED", "JSON_PARSE_FAILED")
return {"success": True, "memory_ids": ["m-1"]}
@staticmethod
def _failed(coding: bool, reconcile_cause: str, coding_code: str) -> dict[str, object]:
if coding:
return {"success": False, "agent_error_code": coding_code}
return {
"success": False,
"error_code": 502,
"agent_error_code": "RECONCILE_FAILED",
"reconcile_error_code": reconcile_cause,
}
class _Sdk2013Handle:
mode = "pro"
def __init__(self, client: _Sdk2013Client) -> None:
self.sdk = client
async def client(self) -> _Sdk2013Client:
return self.sdk
class _Sdk2013Supervisor:
def __init__(self, client: _Sdk2013Client) -> None:
self.processor = SdkWriteProcessor(_Sdk2013Handle(client)) # type: ignore[arg-type]
async def process_sdk_job(self, records, *, request_id, before_hy_invocation):
return await self.processor.process_sdk_job(
records, request_id=request_id, before_hy_invocation=before_hy_invocation
)
async def inquire_write_outcome(self, _request_id):
return None
class _Turns:
"""Evidence for one conversation batch: a chat turn, or a coding turn with tools."""
def __init__(self, roles: tuple[str, ...]) -> None:
self.records = {
f"e-{index}": materialize_write_input(
EvidenceInput(
user_id="local",
source_id="cursor",
conversation_id="sess-1",
message_id=f"msg-{index}",
role=role,
written_by=WrittenBy.PERSON,
created_at=datetime(2026, 10, 1, tzinfo=UTC),
verbatim=f"{role} says something worth keeping ({index})",
origin_meta={EvidenceOrigin.FILE: OriginMetadata()},
)
)
for index, role in enumerate(roles)
}
def get(self, evidence_id: str):
return self.records[evidence_id]
def request(self) -> WriteRequest:
return WriteRequest(
internal_session_id="sess-1",
batch_identity=BatchIdentity(internal_session_id="sess-1", first_round=1, last_round=1),
evidence_ids=list(self.records),
idempotency_key="idem-000",
source_id="cursor",
)
def _outage_then_recovery(tmp_path, roles: tuple[str, ...], failure) -> tuple[list, list]:
clock = _Clock()
backoff = ModelBackoff(clock=clock)
calls: list[float] = []
def provider(request: httpx.Request) -> httpx.Response:
calls.append(clock.now)
if clock.now < 60.0:
return failure(request)
return httpx.Response(200)
client = _Sdk2013Client(
BackedOffModelTransport(backoff, profile_id="p:1", inner=httpx.MockTransport(provider))
)
turns = _Turns(roles)
path = tmp_path / "one-memory.sqlite3"
queue = DurableWriteQueue(path)
queue.submit(turns.request())
drain = DurableWriteDrain(
queue, execute=_never_execute, owner="outage", model_claim_allowance=backoff.allowance
)
seen: list[tuple[str, int, int, str | None]] = []
for second in range(120):
clock.now = float(second)
for job_id in drain.claim(limit=1):
asyncio.run(
execute_job(
job_id,
queue=queue,
supervisor=_Sdk2013Supervisor(client),
evidence=turns,
owner="outage",
model_failures=backoff.attempt,
)
)
seen.append(_jobs(path)[0][1:])
assert all(len(sent) == len(roles) for sent in client.messages)
return calls, seen
def _timeout(request: httpx.Request) -> httpx.Response:
raise httpx.ReadTimeout("provider timed out", request=request)
@pytest.mark.parametrize(
("failure", "code"),
[
(lambda _request: httpx.Response(401), "model_auth_failed"),
(lambda _request: httpx.Response(503), "model_server_error"),
(_timeout, "model_timeout"),
],
ids=["401", "503", "timeout"],
)
def test_a_reconcile_model_failure_waits_for_the_provider_and_completes_after_it(
tmp_path, failure, code
) -> None:
"""hy-memory 2.0.13 fails the write when reconcile cannot reach its model.
The breaker saw the provider fail, so the job waits in place without
spending a retry, is tried once per pause, and lands after recovery.
"""
calls, seen = _outage_then_recovery(tmp_path, ("user", "assistant"), failure)
assert calls == [0.0, 5.0, 15.0, 35.0, 75.0]
assert seen[:-1] == [("pending", attempt, 0, code) for attempt in range(1, 5)]
assert seen[-1] == ("completed", 4, 0, None)
def test_a_coding_batch_whose_extractor_fails_at_the_provider_waits_like_chat(tmp_path) -> None:
"""Tool turns go down the SDK's coding path; its `LLM_ERROR` waits the same way."""
calls, seen = _outage_then_recovery(
tmp_path, ("user", "assistant", "tool", "assistant"), lambda _request: httpx.Response(502)
)
assert calls == [0.0, 5.0, 15.0, 35.0, 75.0]
assert seen[:-1] == [("pending", attempt, 0, "model_server_error") for attempt in range(1, 5)]
assert seen[-1] == ("completed", 4, 0, None)
@pytest.mark.parametrize(
("reply", "code"),
[
("", "hy_sdk_reconcile_empty_response"),
("I am not sure what you mean.", "hy_sdk_reconcile_json_parse_failed"),
],
ids=["empty", "unparseable"],
)
def test_a_reconcile_reply_the_sdk_cannot_use_is_retried_then_failed_with_its_cause(
tmp_path, reply, code
) -> None:
"""HTTP 200 with nothing usable is not a provider failure: 3 retries, then `failed`.
The job's `error_code` names the SDK's cause instead of a bare
`hy_sdk_write_failed`, and the provider is never paused.
"""
backoff = ModelBackoff(clock=_Clock())
client = _Sdk2013Client(
BackedOffModelTransport(
backoff, profile_id="p:1", inner=httpx.MockTransport(lambda _r: httpx.Response(200))
)
)
client.reply = reply
turns = _Turns(("user", "assistant"))
path = tmp_path / "one-memory.sqlite3"
queue = DurableWriteQueue(path)
queue.submit(turns.request())
drain = DurableWriteDrain(queue, execute=_never_execute, owner="bad-reply")
now = datetime.now(UTC)
for offset in (0, 40, 150, 400):
for job_id in drain.claim(limit=1, now=now + timedelta(seconds=offset)):
asyncio.run(
execute_job(
job_id,
queue=queue,
supervisor=_Sdk2013Supervisor(client),
evidence=turns,
owner="bad-reply",
model_failures=backoff.attempt,
)
)
assert _jobs(path)[0][1:] == ("failed", 3, 3, code)
assert queue.get("idem-000").error_code == code
assert len(client.messages) == 4
assert backoff.allowance() is None
@pytest.mark.parametrize(
("result", "code"),
[
(
{"agent_error_code": "RECONCILE_FAILED", "reconcile_error_code": "LLM_UNAVAILABLE"},
"hy_sdk_reconcile_llm_unavailable",
),
(
{"agent_error_code": "RECONCILE_FAILED", "reconcile_error_code": "RECONCILE_EXCEPTION"},
"hy_sdk_reconcile_exception",
),
({"agent_error_code": "RECONCILE_FAILED"}, "hy_sdk_reconcile_failed"),
({"agent_error_code": "EMBEDDING_FAILED"}, "hy_sdk_embedding_failed"),
({"agent_error_code": "JSON_PARSE_FAILED"}, "hy_sdk_json_parse_failed"),
({"agent_error_code": "provider said: bad key sk-123"}, "hy_sdk_write_failed"),
({"agent_error_code": 502}, "hy_sdk_write_failed"),
({}, "hy_sdk_write_failed"),
],
)
def test_a_failed_sdk_write_keeps_only_the_sdks_stable_code(result, code) -> None:
assert sdk_write_failure_code({"success": False, **result}) == code
@@ -35,6 +35,12 @@ def test_profile_uses_one_model_and_keeps_key_out_of_database(tmp_path) -> None:
)
assert profile.has_api_key is True
assert profile.embedding_provider_kind is ProviderKind.LOCAL_ONNX
assert profile.embedding_model == "bge-small-zh-v1.5"
assert profile.embedding_dimensions == 512
assert profile.timeout_seconds == 240
assert profile.max_concurrency == 4
assert profile.context_window is None
assert "api_key" not in profile.model_dump()
assert API_KEY.encode() not in database.read_bytes()
assert secrets.resolve(store.secret_ref(profile.profile_id) or "") == API_KEY
@@ -1,13 +1,17 @@
"""Onboarding endpoints manage UI state without touching SDK memory."""
import asyncio
import sqlite3
from datetime import UTC, datetime
from pathlib import Path
import pytest
from httpx import ASGITransport, AsyncClient
from pydantic import SecretStr
from one_memory.api import create_app
from one_memory.config import Settings
from one_memory.onboarding import OnboardingStore
def _app(tmp_path: Path):
@@ -63,13 +67,29 @@ def test_reset_returns_to_first_run_without_touching_sdk_data(tmp_path: Path) ->
sdk_marker = tmp_path / "data" / "hy-memory" / "governed-v2" / "memory.marker"
sdk_marker.parent.mkdir(parents=True, exist_ok=True)
sdk_marker.write_text("keep-sdk-memory", encoding="utf-8")
progress = await client.patch(
"/api/v1/onboarding/progress",
json={
"mode_intent": "pro",
"setup_step": "model",
"model_profile_id": "9be6d465-4cf2-4c72-93f7-15f3c012bc80",
},
)
assert progress.status_code == 200
assert (await client.post("/api/v1/onboarding/complete")).status_code == 200
completed = await client.post("/api/v1/onboarding/complete")
assert completed.status_code == 200
assert completed.json()["mode_intent"] is None
assert completed.json()["setup_step"] is None
assert completed.json()["model_profile_id"] is None
reset = await client.post("/api/v1/onboarding/reset")
assert reset.status_code == 200
assert reset.json()["status"] == "not_started"
assert reset.json()["completed_at"] is None
assert reset.json()["mode_intent"] is None
assert reset.json()["setup_step"] is None
assert reset.json()["model_profile_id"] is None
assert (await client.get("/api/v1/onboarding")).json()["status"] == "not_started"
assert sdk_marker.read_text(encoding="utf-8") == "keep-sdk-memory"
@@ -92,6 +112,7 @@ def test_sdk_unavailability_cannot_block_onboarding_completion(tmp_path: Path) -
async def scenario() -> None:
app = _app(tmp_path)
async with app.router.lifespan_context(app), _client(app) as client:
async def unavailable() -> dict[str, object]:
raise RuntimeError("hy_sdk_unavailable")
@@ -102,3 +123,197 @@ def test_sdk_unavailability_cannot_block_onboarding_completion(tmp_path: Path) -
assert finished.json()["status"] == "completed"
asyncio.run(scenario())
def test_progress_patch_and_put_round_trip_only_resumable_choices(tmp_path: Path) -> None:
async def scenario() -> None:
app = _app(tmp_path)
async with app.router.lifespan_context(app), _client(app) as client:
patched = await client.patch(
"/api/v1/onboarding/progress",
json={"mode_intent": "pro", "setup_step": "mode"},
)
assert patched.status_code == 200
assert patched.json()["mode_intent"] == "pro"
assert patched.json()["setup_step"] == "mode"
assert patched.json()["model_profile_id"] is None
profile_id = "9be6d465-4cf2-4c72-93f7-15f3c012bc80"
replaced = await client.put(
"/api/v1/onboarding/progress",
json={"setup_step": "model", "model_profile_id": profile_id},
)
assert replaced.status_code == 200
assert replaced.json()["mode_intent"] == "pro"
assert replaced.json()["setup_step"] == "model"
assert replaced.json()["model_profile_id"] == profile_id
assert (await client.get("/api/v1/onboarding")).json() == replaced.json()
asyncio.run(scenario())
def test_progress_rejects_unknown_or_invalid_fields(tmp_path: Path) -> None:
async def scenario() -> None:
app = _app(tmp_path)
async with app.router.lifespan_context(app), _client(app) as client:
secret = await client.patch(
"/api/v1/onboarding/progress",
json={"mode_intent": "lite", "api_key": "must-not-be-stored"},
)
invalid_id = await client.put(
"/api/v1/onboarding/progress",
json={"model_profile_id": "not-a-profile-id"},
)
invalid_mode = await client.patch(
"/api/v1/onboarding/progress",
json={"mode_intent": "ultra"},
)
assert secret.status_code == 422
assert "must-not-be-stored" not in secret.text
assert invalid_id.status_code == 422
assert invalid_mode.status_code == 422
state = (await client.get("/api/v1/onboarding")).json()
assert state["mode_intent"] is None
assert state["model_profile_id"] is None
asyncio.run(scenario())
def test_progress_row_adapter_accepts_mysql_tuple_shape() -> None:
started_at = datetime(2026, 11, 4, 1, 0, tzinfo=UTC)
updated_at = datetime(2026, 11, 4, 1, 1)
state = OnboardingStore._to_state(
(
1,
"importing",
'["cursor"]',
started_at,
None,
updated_at,
"lite",
"review",
None,
),
mysql=True,
)
assert state.selected_source_ids == ("cursor",)
assert state.started_at == started_at
assert state.mode_intent == "lite"
assert state.setup_step == "review"
def test_progress_row_adapter_accepts_real_sqlite_row_and_text_datetime() -> None:
with sqlite3.connect(":memory:") as connection:
connection.row_factory = sqlite3.Row
row = connection.execute(
"SELECT 1 AS singleton, 'importing' AS status, '[\"cursor\"]' "
"AS selected_source_ids_json, '2026-11-04T01:00:00+00:00' AS started_at, "
"NULL AS completed_at, '2026-11-04T01:01:00+00:00' AS updated_at, "
"'pro' AS mode_intent, 'model' AS setup_step, "
"'9be6d465-4cf2-4c72-93f7-15f3c012bc80' AS model_profile_id"
).fetchone()
assert row is not None
state = OnboardingStore._to_state(row, mysql=False)
assert state.started_at == datetime(2026, 11, 4, 1, 0, tzinfo=UTC)
assert state.mode_intent == "pro"
assert state.setup_step == "model"
def test_progress_row_adapter_rejects_wrong_length() -> None:
with pytest.raises(ValueError, match="onboarding_row_shape_invalid"):
OnboardingStore._to_state((1, "importing"), mysql=True)
def test_progress_row_adapter_rejects_wrong_row_types() -> None:
values = (
1,
"importing",
"[]",
None,
None,
datetime(2026, 11, 4, 1, 1),
None,
None,
None,
)
with pytest.raises(ValueError, match="onboarding_row_shape_invalid"):
OnboardingStore._to_state(list(values), mysql=True)
with pytest.raises(ValueError, match="onboarding_row_shape_invalid"):
OnboardingStore._to_state(values, mysql=False)
def test_progress_row_adapter_rejects_wrong_engine_datetime_shape() -> None:
with pytest.raises(ValueError, match="onboarding_datetime_invalid"):
OnboardingStore._to_state(
(
1,
"importing",
"[]",
"2026-11-04T01:00:00+00:00",
None,
datetime(2026, 11, 4, 1, 1),
None,
None,
None,
),
mysql=True,
)
def test_runtime_sqlite_upgrade_does_not_record_progress_migration(tmp_path: Path) -> None:
database = tmp_path / "old.sqlite3"
with sqlite3.connect(database) as connection:
connection.executescript(
"""
CREATE TABLE schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL
);
INSERT INTO schema_migrations VALUES (61, 'write_job_retries', '2026-11-03');
CREATE TABLE onboarding_state (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
status TEXT NOT NULL,
selected_source_ids_json TEXT NOT NULL,
started_at TEXT,
completed_at TEXT,
updated_at TEXT NOT NULL
);
"""
)
OnboardingStore(database)
with sqlite3.connect(database) as connection:
recorded = connection.execute(
"SELECT name FROM schema_migrations WHERE version=62"
).fetchone()
columns = {str(row[1]) for row in connection.execute("PRAGMA table_info(onboarding_state)")}
assert recorded is None
assert {"mode_intent", "setup_step", "model_profile_id"} <= columns
def test_progress_rejects_updates_after_completion(tmp_path: Path) -> None:
async def scenario() -> None:
app = _app(tmp_path)
async with app.router.lifespan_context(app), _client(app) as client:
assert (await client.post("/api/v1/onboarding/complete")).status_code == 200
populated = await client.patch(
"/api/v1/onboarding/progress",
json={"mode_intent": "lite"},
)
empty = await client.put("/api/v1/onboarding/progress", json={})
assert populated.status_code == 409
assert populated.json()["detail"] == "onboarding_completed"
assert empty.status_code == 409
state = (await client.get("/api/v1/onboarding")).json()
assert state["status"] == "completed"
assert state["mode_intent"] is None
asyncio.run(scenario())
+58 -2
View File
@@ -42,7 +42,10 @@ RAW_ROWS = {
"local": [
{
"memory_id": "raw-1",
"content": "[user]: 我叫费天翔\n[assistant]: 好的,记住了。",
"content": (
"[user]: 我叫费天翔\n[assistant]: 好的,记住了。"
"\n[user]: 我喜欢绿茶\n[assistant]: 也记住了。"
),
"status": "shadow",
"session_id": "s-1",
"source": "hermes",
@@ -347,6 +350,33 @@ 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"]
assert sdk.last("search")["statuses"] == ["active", "shadow"]
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 +604,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 +619,27 @@ def test_raw_lists_active_and_shadow_turns_with_roles_and_hashes(tmp_path, monke
second = next(item for item in body["items"] if item["raw_id"] == "raw-2")
assert second["role"] == "user"
turns = (await client.get("/api/v2/raw", params={"unit": "turn"})).json()
assert turns["unit"] == "turn" and turns["total"] == 3
first_turns = [item for item in turns["items"] if item["raw_id"] == "raw-1"]
assert [item["turn_index"] for item in first_turns] == [1, 0]
assert len({item["turn_id"] for item in first_turns}) == 2
assert [[message["content"] for message in item["messages"]] for item in first_turns] == [
["我喜欢绿茶", "也记住了。"],
["我叫费天翔", "好的,记住了。"],
]
calls_before_refresh = len(sdk.all("list_memories"))
await client.get("/api/v2/raw", params={"unit": "turn"})
assert len(sdk.all("list_memories")) == calls_before_refresh + 1
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")],
@@ -1613,7 +1613,7 @@ def test_a_completed_sdk_write_does_not_touch_retired_evidence(store) -> None:
class Supervisor:
async def process_sdk_job(self, records, *, request_id, before_hy_invocation):
del records, request_id
await before_hy_invocation()
await before_hy_invocation(True)
return SimpleNamespace(raw_ids=("raw-1",), memory_ids=("m-1",))
asyncio.run(
@@ -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.12"
assert manifest["snapshot_sdk_version"] == "2.0.13"
paths = {item["path"]: item for item in manifest["files"]}
assert {
"management/one-memory.sqlite3",
@@ -1,13 +1,12 @@
"""The sedimentation API persists decisions and resumes the SDK queue."""
import asyncio
import threading
"""The sedimentation API persists decisions without rewriting parked work."""
from fastapi.testclient import TestClient
from pydantic import SecretStr
from one_memory.api import create_app
from one_memory.config import Settings
from one_memory.memory_server.contracts import BatchIdentity, WriteRequest
from one_memory.memory_server.durable_queue import WAITING_FOR_PERMISSION
def _app(tmp_path):
@@ -25,51 +24,62 @@ def _app(tmp_path):
)
def test_sedimentation_route_persists_and_resumes_without_restart(tmp_path) -> None:
def test_sedimentation_route_persists_without_restart(tmp_path) -> None:
with TestClient(_app(tmp_path), headers={"Authorization": "Bearer test"}) as client:
assert client.get("/api/v1/sedimentation").json()["enabled"] is True
assert client.put(
"/api/v1/sedimentation", json={"enabled": False}
).json()["enabled"] is False
assert (
client.put("/api/v1/sedimentation", json={"enabled": False}).json()["enabled"] is False
)
assert client.get("/api/v1/sedimentation").json()["enabled"] is False
assert client.put(
"/api/v1/sedimentation", json={"enabled": True}
).json()["enabled"] is True
assert client.put("/api/v1/sedimentation", json={"enabled": True}).json()["enabled"] is True
def test_switch_answers_before_sdk_queue_resume_finishes(tmp_path, monkeypatch) -> None:
def test_switch_does_not_run_the_broad_parked_queue_resume(tmp_path, monkeypatch) -> None:
app = _app(tmp_path)
released = threading.Event()
finished = threading.Event()
called = False
with TestClient(app, headers={"Authorization": "Bearer test"}) as client:
async def slow_resume() -> None:
await asyncio.get_running_loop().run_in_executor(None, released.wait, 10.0)
finished.set()
monkeypatch.setattr(app.state.sdk_memory_runtime, "resume_allowed", slow_resume)
assert client.put(
"/api/v1/sedimentation", json={"enabled": False}
).status_code == 200
async def forbidden_resume() -> None:
nonlocal called
called = True
monkeypatch.setattr(app.state.sdk_memory_runtime, "resume_allowed", forbidden_resume)
assert client.put("/api/v1/sedimentation", json={"enabled": False}).status_code == 200
resumed = client.put("/api/v1/sedimentation", json={"enabled": True})
assert resumed.status_code == 200
assert resumed.json()["enabled"] is True
assert finished.is_set() is False
released.set()
assert called is False
def test_a_failing_sdk_queue_resume_does_not_fail_the_switch(tmp_path, monkeypatch) -> None:
def test_switch_does_not_rewrite_permission_parked_jobs(tmp_path) -> None:
app = _app(tmp_path)
with TestClient(app, headers={"Authorization": "Bearer test"}) as client:
async def broken_resume() -> None:
raise RuntimeError("sdk_queue_unavailable")
queue = app.state.memory_write_queue
queue.evidence = None
request = WriteRequest(
internal_session_id="session-1",
source_id="cursor",
batch_identity=BatchIdentity(
internal_session_id="session-1",
first_round=1,
last_round=1,
),
evidence_ids=["e-1"],
idempotency_key="permission-parked",
)
record = queue.submit(request)
queue.settle(
record.job_id,
state=WAITING_FOR_PERMISSION,
error_code="model_content_permission_required",
hy_invoked=False,
)
monkeypatch.setattr(app.state.sdk_memory_runtime, "resume_allowed", broken_resume)
assert client.put(
"/api/v1/sedimentation", json={"enabled": False}
).status_code == 200
resumed = client.put("/api/v1/sedimentation", json={"enabled": True})
assert client.put("/api/v1/sedimentation", json={"enabled": False}).status_code == 200
assert client.put("/api/v1/sedimentation", json={"enabled": True}).status_code == 200
assert resumed.status_code == 200
assert resumed.json()["enabled"] is True
parked = queue.get("permission-parked")
assert parked.state == WAITING_FOR_PERMISSION
assert parked.detail == "model_content_permission_required"
@@ -16,7 +16,7 @@ from one_memory.imports.connections import SourceConnectionStore
from one_memory.imports.grants import SourceGrantStore
from one_memory.imports.jobs import ImportJobStore
from one_memory.imports.sources.registry import build_source_catalog
from one_memory.onboarding import OnboardingStore
from one_memory.onboarding import OnboardingProgressUpdate, OnboardingStore
@pytest.mark.parametrize("entry", ["connect", "authorize"])
@@ -371,7 +371,14 @@ def test_onboarding_persists_one_history_window_on_every_accepted_source(tmp_pat
database = tmp_path / "data.sqlite"
grants = SourceGrantStore(database, home=home)
jobs = ImportJobStore(database)
OnboardingStore(database)
onboarding = OnboardingStore(database)
onboarding.update_progress(
OnboardingProgressUpdate(
mode_intent="pro",
setup_step="model",
model_profile_id="9be6d465-4cf2-4c72-93f7-15f3c012bc80",
)
)
store = SourceConnectionStore(grants, jobs, home=home)
catalog = build_source_catalog(home=home, environment={})
cutoff = datetime(2026, 10, 13, 5, 0, tzinfo=UTC)
@@ -391,3 +398,7 @@ def test_onboarding_persists_one_history_window_on_every_accepted_source(tmp_pat
assert receipt.accepted_source_ids == ("claude-code", "codex")
assert {job.history_after for job in receipt.jobs} == {cutoff}
assert store.get("bounded-first-import") == receipt
state = onboarding.get()
assert state.mode_intent == "pro"
assert state.setup_step == "model"
assert state.model_profile_id == "9be6d465-4cf2-4c72-93f7-15f3c012bc80"
+4 -4
View File
@@ -991,7 +991,7 @@ wheels = [
[[package]]
name = "hy-memory"
version = "2.0.12"
version = "2.0.13"
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/44/19/bcd0de44ae2011ee1ad70579996aad72e18f0ca292df22d1545db05db850/hy_memory-2.0.12.tar.gz", hash = "sha256:c1231596b4cf12595775541f81680d4819461cb69e4aa4dc449f876d4c3b6742", size = 1246969, upload-time = "2026-11-02T06:30:37.448Z" }
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" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/75/9e/b64c7643c1bad1ddca4623a39bb62423bed0c22e7fd0d86be1ff73b2a17a/hy_memory-2.0.12-py3-none-any.whl", hash = "sha256:7a2332288197fafb89d5037eac7fef97df4c80794ead18159d21da84cde431df", size = 1316922, upload-time = "2026-11-02T06:30:35.105Z" },
{ 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" },
]
[[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.12" },
{ name = "hy-memory", specifier = "==2.0.13" },
{ name = "keyring", specifier = ">=25.7.0" },
{ name = "numpy", specifier = "==2.5.2" },
{ name = "onnxruntime", specifier = "==1.23.2" },
+34 -1
View File
@@ -9,6 +9,11 @@ import {
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { App } from "./App";
import {
commitConfirmedMemoryMode,
issueConfirmedModeTicket,
resetConfirmedMemoryModeForTests,
} from "./confirmed-memory-mode";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
it("localizes shared navigation and footer in Spanish", async () => {
@@ -36,6 +41,7 @@ it("localizes shared navigation and footer in Spanish", async () => {
});
const memoryProps = vi.hoisted(() => vi.fn());
const progressProps = vi.hoisted(() => vi.fn());
vi.mock("./api", () => ({
getOnboarding: vi.fn().mockResolvedValue({ status: "completed" }),
@@ -62,11 +68,21 @@ vi.mock("./MemorySpace", () => ({
vi.mock("./ModelSettings", () => ({
ModelSettings: () => <p>模型设置内容</p>,
}));
vi.mock("./ImportProgressStrip", () => ({ ImportProgressStrip: () => null }));
vi.mock("./R6Settings", () => ({
R6Settings: () => <p>设置视图内容</p>,
}));
vi.mock("./ImportProgressStrip", () => ({
ImportProgressStrip: (props: unknown) => {
progressProps(props);
return null;
},
}));
vi.mock("./HealthPanel", () => ({ HealthPanel: () => null }));
vi.mock("./OnboardingFlow", () => ({ OnboardingFlow: () => null }));
beforeEach(() => {
resetConfirmedMemoryModeForTests();
progressProps.mockClear();
window.history.replaceState({}, "", "/");
localStorage.clear();
});
@@ -105,6 +121,23 @@ it("uses explicit memory deep links and restores the applications default URL",
).toBe("page");
});
it("passes confirmed mode capabilities to the production progress strip", async () => {
commitConfirmedMemoryMode("lite", issueConfirmedModeTicket("settings"));
render(<App />);
const nav = await screen.findByRole("navigation", { name: "主要导航" });
await userEvent.click(within(nav).getByRole("button", { name: "设置" }));
await waitFor(() =>
expect(progressProps).toHaveBeenCalledWith(
expect.objectContaining({
modeCapabilities: expect.objectContaining({
content_plane: "raw",
generation_required: false,
}),
}),
),
);
});
it("keeps an independent scroll position for each primary page", async () => {
render(<App />);
const nav = await screen.findByRole("navigation", { name: "主要导航" });
+39 -2
View File
@@ -37,7 +37,41 @@ const { getOnboarding, sources, modelProps, mutations } = vi.hoisted(() => ({
vi.mock("./api", () => ({
getOnboarding,
getModelProfiles: vi.fn().mockResolvedValue([]),
getMemoryMode: vi.fn().mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: false,
forced_by_env: false,
modes: ["lite", "pro", "ultra"],
graph_build: {
state: "idle",
started_at: null,
finished_at: null,
users: {},
},
}),
getEvolveSchedule: vi.fn().mockResolvedValue({
enabled: true,
time: "03:00",
}),
getEvolveOverview: vi.fn().mockResolvedValue({
mode: "pro",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
}),
getMemoryProcessing: vi.fn().mockResolvedValue({
embedding_switch: null,
}),
setMemoryMode: vi.fn(),
setEvolveSchedule: vi.fn(),
startEvolve: vi.fn(),
apiErrorCode: vi.fn().mockReturnValue(null),
getSources: vi.fn().mockResolvedValue(sources),
getCurrentEmbeddingSwitch: vi
.fn()
.mockRejectedValue(new Error("unsupported")),
embeddingSwitchUnsupported: vi.fn().mockReturnValue(true),
...mutations,
}));
vi.mock("./use-import-sources", () => ({
@@ -119,7 +153,9 @@ it("enters D47 settings without a tour or mandatory second scope-confirmation fl
).toBeNull();
noModelWrites();
expect(screen.queryByRole("button", { name: "管理已有授权" })).toBeNull();
expect(screen.getByRole("button", { name: "模型" })).toBeTruthy();
expect(screen.getByRole("button", { name: "界面语言" })).toBeTruthy();
expect(screen.getByRole("button", { name: "记忆模式" })).toBeTruthy();
expect(screen.getByRole("button", { name: "模型配置" })).toBeTruthy();
expect(screen.getByRole("button", { name: "连接其他应用" })).toBeTruthy();
expect(screen.getByRole("button", { name: "帮助与诊断" })).toBeTruthy();
noModelWrites();
@@ -182,13 +218,14 @@ it("keeps an open model draft while switching settings groups", async () => {
window.history.replaceState({}, "", "/?view=settings");
render(<App />);
await waitFor(() => expect(modelProps).toHaveBeenCalled());
await userEvent.click(screen.getByRole("button", { name: "模型配置" }));
await userEvent.click(screen.getByRole("button", { name: "配置模型" }));
await userEvent.type(
screen.getByLabelText("保留的模型草稿"),
"unsaved draft",
);
await userEvent.click(screen.getByRole("button", { name: "连接其他应用" }));
await userEvent.click(screen.getByRole("button", { name: "模型" }));
await userEvent.click(screen.getByRole("button", { name: "模型配置" }));
expect(window.location.search).toBe("?view=settings");
expect(screen.getByLabelText("保留的模型草稿")).toHaveProperty(
"value",
+17 -4
View File
@@ -23,8 +23,10 @@ import {
import { getOnboarding } from "./api";
import { useApplicationsLanguage } from "./applications-language";
import { CallHistory } from "./CallHistory";
import { useConfirmedMemoryMode } from "./confirmed-memory-mode";
import { DebugLogPanel } from "./DebugLogPanel";
import { DesktopFrame } from "./DesktopFrame";
import { EmbeddingSwitchGlobalStatus } from "./EmbeddingSwitchExperience";
import { useGuiLanguage } from "./gui-language";
import { HealthPanel } from "./HealthPanel";
import { ImportProgressStrip } from "./ImportProgressStrip";
@@ -34,6 +36,7 @@ import { OnboardingFlow } from "./OnboardingFlow";
import { R6Button, R6LocalStorage } from "./R6Controls";
import { R6Settings } from "./R6Settings";
import { prefetchDataRuntime } from "./runtime-state";
import { EmbeddingSwitchProvider } from "./use-embedding-switch";
import "./styles.css";
import "./core-visual.css";
import "./r6-shell.css";
@@ -47,16 +50,19 @@ const rendererStartedAt = Date.now();
export function App() {
return (
<DesktopFrame>
<AppContent />
<DebugLogPanel />
</DesktopFrame>
<EmbeddingSwitchProvider>
<DesktopFrame>
<AppContent />
<DebugLogPanel />
</DesktopFrame>
</EmbeddingSwitchProvider>
);
}
function AppContent() {
const { t } = useGuiLanguage();
const { a } = useApplicationsLanguage();
const confirmedMode = useConfirmedMemoryMode();
const [onboarding, setOnboarding] = useState<OnboardingState | null>(null);
const [startupRead, setStartupRead] = useState<"loading" | "ready" | "error">(
"loading",
@@ -309,6 +315,10 @@ function AppContent() {
ref={content}
data-view={displayedView}
>
<EmbeddingSwitchGlobalStatus
onConfigureSearch={() => navigate("settings")}
onDiagnostics={() => navigate("health")}
/>
{/* First run hands the user in here while the import is still going.
* Without this the work would be invisible from every screen except
* the one they were no longer required to sit on. */}
@@ -322,6 +332,9 @@ function AppContent() {
sourceSnapshot={imports.sourceSnapshot}
onRefresh={imports.refresh}
arrival={memoryArrival}
modeCapabilities={
confirmedMode.capabilities ?? memoryArrival?.capabilities
}
onConfigureModel={() => navigate("settings")}
onJobAction={imports.changeJob}
showSourceLink={displayedView !== "settings"}
+316 -18
View File
@@ -15,6 +15,11 @@ import {
CALL_HISTORY_ENTRY_VISIBLE,
} from "./ApplicationsHome";
import * as api from "./api";
import {
commitConfirmedMemoryMode,
issueConfirmedModeTicket,
resetConfirmedMemoryModeForTests,
} from "./confirmed-memory-mode";
import { saveConnectionPreferences } from "./connection-preferences";
import { resetConnectionReconcile } from "./connection-reconcile";
import { LanguageProvider } from "./gui-language";
@@ -67,21 +72,37 @@ it("localizes the whole home live without repeating reads or changing imported t
expect(summary).toHaveBeenCalledTimes(calls);
});
const { summary, processing, sedimentation, history, liveSync } = vi.hoisted(
() => ({
const { summary, projection, processing, sedimentation, history, liveSync } =
vi.hoisted(() => ({
summary: vi.fn(),
projection: vi.fn(),
processing: vi.fn(),
sedimentation: vi.fn(),
history: vi.fn(),
liveSync: vi.fn(),
}),
);
}));
vi.mock("./call-history-client", async (original) => ({
...(await original<typeof import("./call-history-client")>()),
getCallHistory: history,
}));
vi.mock("./api", () => ({
getHomeProjection: projection,
getHomeSummary: summary,
homeProjectionUnsupported: vi.fn().mockReturnValue(true),
getMemoryMode: vi.fn().mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: true,
forced_by_env: false,
modes: ["lite", "pro", "ultra"],
graph_build: {
state: "idle",
started_at: null,
finished_at: null,
users: {},
},
}),
getSdkRaw: vi.fn(),
getLiveSyncHealth: liveSync,
getMemoryProcessing: processing,
getSedimentation: sedimentation,
@@ -106,6 +127,22 @@ const imports = {
sourceSnapshot: { stale: false },
} as unknown as Parameters<typeof ApplicationsHome>[0]["imports"];
beforeEach(() => {
resetConfirmedMemoryModeForTests();
vi.mocked(api.getMemoryMode).mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: true,
forced_by_env: false,
modes: ["lite", "pro", "ultra"],
graph_build: {
state: "idle",
started_at: null,
finished_at: null,
users: {},
},
});
vi.mocked(api.getSdkRaw).mockReset();
vi.mocked(api.homeProjectionUnsupported).mockReturnValue(true);
history
.mockReset()
.mockResolvedValue({ available: true, recording_error: false, items: [] });
@@ -122,6 +159,7 @@ beforeEach(() => {
},
observed_at: "2026-09-19T00:00:00Z",
});
projection.mockReset().mockRejectedValue(new Error("legacy core"));
processing.mockReset().mockResolvedValue({ running: 0, pending: 0 });
sedimentation.mockReset().mockResolvedValue({
enabled: true,
@@ -149,6 +187,218 @@ beforeEach(() => {
]),
} as unknown as NonNullable<typeof window.oneMemory>;
});
it("hides an old projection immediately when confirmed mode changes", async () => {
vi.mocked(api.homeProjectionUnsupported).mockReturnValue(false);
commitConfirmedMemoryMode("pro", issueConfirmedModeTicket("settings"));
projection
.mockResolvedValueOnce({
projection_version: 1,
user_id: "local",
effective_mode: "pro",
capabilities: {
content_plane: "memory",
generation_required: true,
graph_enabled: false,
evolve_available: false,
},
visible_count: 1,
display_state: "excerpt",
latest: {
type: "memory",
memory_id: "old",
scene: "chat",
kind: "chat",
variant: null,
source_id: "app-0",
excerpt: "旧模式投影",
truncated: false,
time: null,
time_basis: "update",
},
observed_at: "2026-11-04T00:00:00Z",
})
.mockImplementationOnce(() => new Promise(() => {}));
render(
<ApplicationsHome
imports={imports}
onManage={() => {}}
onMemories={() => {}}
/>,
);
expect(await screen.findByText("旧模式投影")).toBeTruthy();
act(() =>
commitConfirmedMemoryMode("lite", issueConfirmedModeTicket("settings")),
);
expect(screen.queryByText("旧模式投影")).toBeNull();
expect(screen.getAllByText("正在核对本机原始对话…")).toHaveLength(2);
});
it("renders the lite projection as raw data with mode metadata and no navigation tail", async () => {
vi.mocked(api.homeProjectionUnsupported).mockReturnValue(false);
projection.mockResolvedValue({
projection_version: 1,
user_id: "local",
effective_mode: "lite",
capabilities: {
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
},
visible_count: 7,
display_state: "excerpt",
latest: {
type: "raw",
raw_id: "raw-1",
session_id: "session-1",
source_id: "app-0",
excerpt: "用户原始对话摘要",
truncated: false,
display_state: "excerpt",
time: "2026-09-19T00:00:00Z",
time_basis: "conversation",
},
observed_at: "2026-09-19T00:00:01Z",
});
render(
<ApplicationsHome
imports={imports}
onManage={() => {}}
onMemories={vi.fn()}
onCalls={() => {}}
/>,
);
expect(await screen.findByText("原始对话")).toBeTruthy();
expect(screen.getByText("7")).toBeTruthy();
expect(screen.getByText("用户原始对话摘要")).toBeTruthy();
expect(screen.getByText(/原始对话 · App 0 · 对话时间/)).toBeTruthy();
expect(screen.getByText("lite")).toBeTruthy();
expect(screen.queryByRole("button", { name: "查看记忆" })).toBeNull();
expect(screen.queryByRole("button", { name: "查看记录" })).toBeNull();
});
it.each([
["conversation", "对话时间"],
["import", "导入时间"],
["write", "写入时间"],
["update", "更新时间"],
[null, null],
] as const)(
"renders projection time basis %s truthfully",
async (basis, label) => {
vi.mocked(api.homeProjectionUnsupported).mockReturnValue(false);
projection.mockResolvedValue({
projection_version: 1,
user_id: "local",
effective_mode: "lite",
capabilities: {
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
},
visible_count: 1,
display_state: "excerpt",
latest: {
type: "raw",
raw_id: "raw-basis",
session_id: "session-basis",
source_id: "app-0",
excerpt: "basis excerpt",
truncated: false,
display_state: "excerpt",
time: "2026-09-19T00:00:00Z",
time_basis: basis,
},
observed_at: "2026-09-19T00:00:01Z",
});
render(mountHome());
const activity = (await screen.findByText("basis excerpt")).closest(
"section",
);
const meta = activity?.querySelector("small")?.textContent ?? "";
if (label) expect(meta).toContain(label);
else expect(meta).not.toMatch(/对话时间|导入时间|写入时间|更新时间/);
},
);
it("does not publish a stopped legacy raw fallback", async () => {
vi.mocked(api.getMemoryMode).mockResolvedValue({
mode: "lite",
configured_mode: "lite",
explicitly_configured: true,
forced_by_env: false,
modes: ["lite", "pro", "ultra"],
graph_build: {
state: "idle",
started_at: null,
finished_at: null,
users: {},
},
});
let resolveRaw!: (value: {
user_id: string;
items: [];
total: number;
limit: number;
offset: number;
}) => void;
vi.mocked(api.getSdkRaw).mockReturnValue(
new Promise((resolve) => {
resolveRaw = resolve;
}),
);
const view = render(mountHome(true));
await waitFor(() => expect(api.getSdkRaw).toHaveBeenCalledWith({ limit: 1 }));
view.rerender(mountHome(false));
await act(async () =>
resolveRaw({
user_id: "local",
items: [],
total: 73,
limit: 1,
offset: 0,
}),
);
expect(screen.queryByText("73")).toBeNull();
});
it("uses only total from the legacy raw fallback", async () => {
vi.mocked(api.getMemoryMode).mockResolvedValue({
mode: "lite",
configured_mode: "lite",
explicitly_configured: true,
forced_by_env: false,
modes: ["lite", "pro", "ultra"],
graph_build: {
state: "idle",
started_at: null,
finished_at: null,
users: {},
},
});
vi.mocked(api.getSdkRaw).mockResolvedValue({
user_id: "local",
items: [
{
raw_id: "raw-private",
content: "正文不应进入首页",
user_id: "local",
agent_id: "agent",
session_id: "session",
source: "app-0",
memory_at: null,
gmt_created: null,
custom: {},
},
],
total: 9,
limit: 1,
offset: 0,
});
render(mountHome());
expect(await screen.findByText("9")).toBeTruthy();
expect(screen.queryByText("正文不应进入首页")).toBeNull();
});
it.each([
[true, "Organizando", "Organizando nuevas conversaciones"],
[false, "En pausa", "Se dejó de generar memorias nuevas"],
@@ -197,7 +447,7 @@ it("localizes startup recovery and empty states without leaking errors", async (
"El servicio se está iniciando; se actualizará automáticamente",
),
).toBeTruthy();
expect(screen.getByText("0")).toBeTruthy();
expect(screen.getByText("—")).toBeTruthy();
expect(screen.getByText("Aún no hay aplicaciones para mostrar")).toBeTruthy();
expect(
screen.queryByRole("button", { name: "Reintentar lectura" }),
@@ -235,7 +485,7 @@ it("shows latest recorded call separately without exposing recent-update detail
);
await screen.findByText("实际治理记忆");
fireEvent.click(screen.getByRole("tab", { name: "最近调用" }));
await screen.findByText("Codex 查到 3 条相关内容");
await screen.findByText("Codex 找到 2 条记忆 · 1 条原始会话");
expect(document.querySelector(".r6-home-call-direction")).toBeTruthy();
expect(screen.queryByText("实际治理记忆")).toBeNull();
expect(screen.getByText(/Codex · 今天 .+ · 查询成功/)).toBeTruthy();
@@ -292,7 +542,7 @@ it("skips direct diagnostics and shows the latest attributed application call",
);
fireEvent.click(await screen.findByRole("tab", { name: "最近调用" }));
expect(await screen.findByText("WorkBuddy 查到 5 条相关内容")).toBeTruthy();
expect(await screen.findByText("WorkBuddy 找到 5 条记忆")).toBeTruthy();
expect(screen.queryByText(/直接调用/)).toBeNull();
expect(screen.getByText(/WorkBuddy · 今天 .+ · 查询成功/)).toBeTruthy();
const direction = document.querySelector(".r6-home-call-direction");
@@ -330,7 +580,7 @@ it("shows only the host for memory hits whose references have no source", async
/>,
);
fireEvent.click(await screen.findByRole("tab", { name: "最近调用" }));
await screen.findByText("WorkBuddy 查到 2 条相关内容");
await screen.findByText("WorkBuddy 找到 2 条记忆");
const direction = document.querySelector(".r6-home-call-direction");
expect(direction?.querySelectorAll("img")).toHaveLength(1);
expect(direction?.querySelector("img")?.getAttribute("src")).toContain(
@@ -368,7 +618,7 @@ it("keeps source to host direction for raw hits with a source", async () => {
/>,
);
fireEvent.click(await screen.findByRole("tab", { name: "最近调用" }));
await screen.findByText("WorkBuddy 查到 1 条相关内容");
await screen.findByText("WorkBuddy 找到 1 条原始会话");
const direction = document.querySelector(".r6-home-call-direction");
expect(direction?.querySelectorAll("img")).toHaveLength(1);
expect(direction?.querySelector("svg")).toBeTruthy();
@@ -416,7 +666,9 @@ it("shows unlinked apps and the latest returned call on matching rows", async ()
onCalls={() => {}}
/>,
);
expect(await screen.findByText(/Cursor 查到 3 条相关内容 ·/)).toBeTruthy();
expect(
await screen.findByText(/Cursor 找到 2 条记忆 · 1 条原始会话 ·/),
).toBeTruthy();
expect(screen.getByText("尚未关联")).toBeTruthy();
});
it("keeps the latest call visible when recording is incomplete", async () => {
@@ -444,7 +696,7 @@ it("keeps the latest call visible when recording is incomplete", async () => {
/>,
);
fireEvent.click(await screen.findByRole("tab", { name: "最近调用" }));
await screen.findByText("Cursor 查到 1 条相关内容");
await screen.findByText("Cursor 找到 1 条记忆");
expect(screen.getByText(/有几次调用没被记下来/)).toBeTruthy();
expect(screen.queryByText("暂时无法读取调用记录")).toBeNull();
expect(screen.queryByText("暂无已记录调用")).toBeNull();
@@ -496,7 +748,7 @@ it("marks a retained application activity as last-read when call history cannot
/>,
);
await screen.findByText(/App 0 查到 3 条相关内容 ·/);
await screen.findByText(/App 0 找到 2 条记忆 · 1 条原始会话 ·/);
view.rerender(
<ApplicationsHome
active={false}
@@ -520,8 +772,10 @@ it("marks a retained application activity as last-read when call history cannot
await waitFor(() => expect(history).toHaveBeenCalledTimes(2));
expect(await screen.findByText("实际治理记忆")).toBeTruthy();
expect(screen.getByText(/上次读取:App 0 查到 3 条相关内容/)).toBeTruthy();
expect(screen.queryByText(/^App 0 查到/)).toBeNull();
expect(
screen.getByText(/上次读取:App 0 找到 2 条记忆 · 1 条原始会话/),
).toBeTruthy();
expect(screen.queryByText(/^App 0 找到/)).toBeNull();
});
it("does not say an unavailable call returned memories", async () => {
history.mockResolvedValue({
@@ -770,7 +1024,7 @@ it("keeps authoritative home and processing data when only the source snapshot b
expect(screen.getAllByText("整理中")).toHaveLength(2);
expect(screen.queryByText("状态待核对")).toBeNull();
});
it("keeps a neutral zero while the first home summary read recovers", async () => {
it("keeps an unknown dash while the first home read recovers", async () => {
summary.mockRejectedValue(new Error("private offline detail"));
render(
<ApplicationsHome
@@ -783,7 +1037,7 @@ it("keeps a neutral zero while the first home summary read recovers", async () =
await waitFor(() =>
expect(screen.getByText("后台正在启动,稍后自动更新")).toBeTruthy(),
);
expect(screen.getByText("0")).toBeTruthy();
expect(screen.getByText("—")).toBeTruthy();
expect(screen.getByText("正在准备记忆概览")).toBeTruthy();
expect(screen.queryByText(/暂时?无法读取|读取失败/)).toBeNull();
expect(document.body.textContent).not.toContain("private offline detail");
@@ -944,6 +1198,50 @@ it("shows a paused switch immediately even when task rows still say running", as
expect(screen.queryByText("新的对话正在整理")).toBeNull();
});
it("shows lite raw ingestion as running even when model sedimentation is paused", async () => {
vi.mocked(api.homeProjectionUnsupported).mockReturnValue(false);
projection.mockResolvedValue({
projection_version: 1,
user_id: "local",
effective_mode: "lite",
capabilities: {
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
},
visible_count: 0,
display_state: "empty",
latest: null,
observed_at: "2026-11-04T00:00:00Z",
});
processing.mockResolvedValue({
running: 0,
pending: 10,
pipeline: { mode: "lite" },
});
sedimentation.mockResolvedValue({
enabled: false,
pending_retained: true,
imports_unaffected: true,
});
render(
<ApplicationsHome
imports={imports}
onManage={() => {}}
onMemories={() => {}}
onSettings={() => {}}
/>,
);
expect(await screen.findAllByText("正在保存")).toHaveLength(2);
expect(screen.getByText("暂无更新")).toBeTruthy();
expect(screen.getByText("已核对,暂无可显示的原始对话更新")).toBeTruthy();
expect(screen.getByText("原始对话持续保存,不需要生成模型")).toBeTruthy();
expect(screen.queryByText("已暂停")).toBeNull();
});
it("keeps the import-in-progress empty state stable without a progress link", async () => {
summary.mockResolvedValue({
memory_count: 0,
@@ -1312,7 +1610,7 @@ it("keeps the conversation-sync checking copy before the first home read", () =>
expect(screen.queryByText(/个应用已开启/)).toBeNull();
});
it("shows a provisional zero while the first summary read is pending", async () => {
it("shows an unknown dash while the first projection read is pending", async () => {
summary.mockImplementation(() => new Promise(() => {}));
history.mockResolvedValue({
available: true,
@@ -1329,7 +1627,7 @@ it("shows a provisional zero while the first summary read is pending", async ()
/>,
);
await screen.findByText("正在准备记忆概览");
expect(screen.getByText("0")).toBeTruthy();
expect(screen.getByText("—")).toBeTruthy();
expect(screen.getByText("正在核对本机记忆…")).toBeTruthy();
expect(screen.queryByText("还没有已生成的记忆")).toBeNull();
});
+249 -114
View File
@@ -1,4 +1,5 @@
import type {
HomeProjectionV1,
HostHomeSummary,
LiveSyncHealth,
MemoryProcessingSummary,
@@ -8,7 +9,15 @@ import { type GuiLocale, isMessageKey } from "@one-memory/i18n";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { getHomeSummary, getLiveSyncHealth, getMemoryProcessing } from "./api";
import {
getHomeProjection,
getHomeSummary,
getLiveSyncHealth,
getMemoryMode,
getMemoryProcessing,
getSdkRaw,
homeProjectionUnsupported,
} from "./api";
import { AppMark } from "./app-mark";
import {
type CallEvent,
@@ -17,6 +26,13 @@ import {
callRowDetail,
getCallHistory,
} from "./call-history-client";
import {
commitConfirmedMemoryMode,
confirmedMemoryModeSnapshot,
isConfirmedModeTicketCurrent,
issueConfirmedModeTicket,
useConfirmedMemoryMode,
} from "./confirmed-memory-mode";
import { hostMatchesSource, sameConnectionHost } from "./connection-hosts";
import { readConnectionPreferences } from "./connection-preferences";
import {
@@ -27,6 +43,10 @@ import {
} from "./connection-reconcile";
import { useGuiLanguage } from "./gui-language";
import { HomeCardBoundary } from "./HomeCardBoundary";
import {
buildHomeViewModel,
type HomeProjectionReadState,
} from "./home-view-model";
import {
hostBridgeAvailable,
hostConnectionApproved,
@@ -85,6 +105,17 @@ function stamped(value: string, locale: GuiLocale, t: Translate, suffix = "") {
return `${parts.today ? t("apps.today") : ""}${parts.clock}${suffix}`;
}
function projectionTimeBasisLabel(
basis: string | null,
t: Translate,
): string | null {
if (basis === "conversation") return t("apps.conversationTime");
if (basis === "import") return t("apps.importTime");
if (basis === "write") return t("apps.writeTime");
if (basis === "update") return t("apps.updateTime");
return null;
}
type ActivityChoice = "call" | "update";
function sourceMatchesHost(sourceId: string, hostId: string | null) {
@@ -167,7 +198,6 @@ function SourceMark({ source }: { source: SourceResponse }) {
export function ApplicationsHome({
imports,
onManage,
onMemories,
onCalls,
active = true,
}: {
@@ -192,6 +222,11 @@ export function ApplicationsHome({
const hostStale = hostQuery.isError && hostQuery.data !== undefined;
const auto = useAutoConnect();
const sedimentation = useSedimentation();
const confirmedMode = useConfirmedMemoryMode();
const [projection, setProjection] = useState<HomeProjectionV1 | null>(null);
const [projectionState, setProjectionState] =
useState<HomeProjectionReadState>("loading");
const [legacyCore, setLegacyCore] = useState(false);
const [summary, setSummary] = useState<HostHomeSummary | null>(null);
const [processing, setProcessing] = useState<MemoryProcessingSummary | null>(
null,
@@ -278,6 +313,7 @@ export function ApplicationsHome({
const [expanded, setExpanded] = useState(retained.expanded);
const [phase, setPhase] = useState("idle");
const [revision, setRevision] = useState(0);
const confirmedModeSeen = useRef(confirmedMode);
const expandButton = useRef<HTMLButtonElement>(null);
const backButton = useRef<HTMLButtonElement>(null);
const root = useRef<HTMLElement>(null);
@@ -538,6 +574,18 @@ export function ApplicationsHome({
.map((s) => `${s.source_id}:${s.authorized}:${s.grant_revision}`)
.join("|");
const requestKey = `${sourceVersion}:${revision}`;
useEffect(() => {
const previous = confirmedModeSeen.current;
if (previous.revision === confirmedMode.revision) return;
confirmedModeSeen.current = confirmedMode;
// The first confirmed mode accompanies the first self-consistent
// projection. There is no old-mode projection to hide yet.
if (previous.mode === null) return;
setProjection(null);
setSummary(null);
setProjectionState("loading");
if (active) setRevision((value) => value + 1);
}, [active, confirmedMode]);
// requestKey retriggers this read when the source snapshot or a manual retry changes.
// biome-ignore lint/correctness/useExhaustiveDependencies: requestKey is the refresh token
useEffect(() => {
@@ -547,8 +595,9 @@ export function ApplicationsHome({
async function refresh() {
if (busy || document.hidden) return;
busy = true;
const homeTicket = issueConfirmedModeTicket("home");
const [home, state, sync] = await Promise.allSettled([
getHomeSummary(),
getHomeProjection(),
getMemoryProcessing(),
getLiveSyncHealth(),
refreshSedimentation(),
@@ -556,17 +605,99 @@ export function ApplicationsHome({
busy = false;
if (stopped) return;
if (home.status === "fulfilled") {
// stale=true 表示这轮读取超时,数字不代表现状:保留上一次的概览,
// 等 10 秒后的下一轮轮询补上。重复 stale 仍要进入可恢复状态,
// 不能永远停在“正在核对”。
if (!("stale" in home.value) || !home.value.stale) {
setSummaryFailures(0);
setSummary(home.value);
} else {
setSummaryFailures((count) => count + 1);
}
if (
!commitConfirmedMemoryMode(
home.value.effective_mode,
homeTicket,
home.value.capabilities,
)
)
return;
setLegacyCore(false);
setSummaryFailures(0);
setProjection(home.value);
setProjectionState("ready");
} else {
setSummaryFailures((count) => count + 1);
if (homeProjectionUnsupported(home.reason)) {
setLegacyCore(true);
try {
const modeTicket = issueConfirmedModeTicket("home");
const modeResult = await getMemoryMode();
if (stopped) return;
const modeAccepted = commitConfirmedMemoryMode(
modeResult.mode,
modeTicket,
);
const effectiveMode = modeAccepted
? modeResult.mode
: confirmedMemoryModeSnapshot().mode;
if (effectiveMode === "lite") {
const raw = await getSdkRaw({ limit: 1 });
if (stopped || !isConfirmedModeTicketCurrent(modeTicket)) return;
setProjection({
projection_version: 1,
user_id: raw.user_id ?? "local",
effective_mode: "lite",
capabilities: {
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
},
visible_count: raw.total,
display_state: raw.total > 0 ? "no_user_excerpt" : "empty",
latest: null,
observed_at: new Date().toISOString(),
});
setSummaryFailures(0);
setProjectionState("ready");
return;
}
const legacy = await getHomeSummary();
if (stopped || !isConfirmedModeTicketCurrent(modeTicket)) return;
if (!("stale" in legacy) || !legacy.stale) {
setSummary(legacy);
setSummaryFailures(0);
setProjectionState("ready");
} else {
setSummaryFailures((count) => {
const next = count + 1;
setProjectionState((current) =>
next < 2
? current
: current === "ready" || current === "stale"
? "stale"
: "unavailable",
);
return next;
});
}
} catch {
setSummaryFailures((count) => {
const next = count + 1;
setProjectionState((current) =>
next < 2
? current
: current === "ready" || current === "stale"
? "stale"
: "unavailable",
);
return next;
});
}
} else {
setSummaryFailures((count) => {
const next = count + 1;
setProjectionState((current) =>
next < 2
? current
: current === "ready" || current === "stale"
? "stale"
: "unavailable",
);
return next;
});
}
}
if (state.status === "fulfilled") setProcessing(state.value);
if (sync.status === "fulfilled") setSyncHealth(sync.value);
@@ -582,10 +713,31 @@ export function ApplicationsHome({
stop();
};
}, [active, requestKey]);
const visible = summary;
const latest = visible?.latest;
const summaryUnavailable = visible === null && summaryFailures >= 2;
const summaryStale = visible !== null && summaryFailures >= 2;
const modeChangedPending =
confirmedModeSeen.current.mode !== null &&
confirmedModeSeen.current.revision !== confirmedMode.revision;
const effectiveProjection = modeChangedPending ? null : projection;
const visible = modeChangedPending ? null : summary;
const historyImporting = imports.jobs.some(
(job) => job.status === "running" || job.status === "pending",
);
const viewModel = buildHomeViewModel({
projection: effectiveProjection,
projectionState: modeChangedPending ? "loading" : projectionState,
confirmedMode: confirmedMode.mode,
legacySummary: legacyCore ? visible : null,
processing,
sedimentation: sedimentation ?? null,
recentCall,
importsRunning: historyImporting,
hostState,
syncEnabledCount: imports.sources.filter(
(source) => source.authorized && source.sync_enabled,
).length,
});
const latest = viewModel.recent;
const summaryUnavailable = viewModel.state === "unavailable";
const summaryStale = viewModel.state === "stale";
const hasCall =
recentCall !== null &&
(callState === "ready" || callState === "incomplete");
@@ -599,9 +751,6 @@ export function ApplicationsHome({
activityView !== null && availableActivities.includes(activityView)
? activityView
: null;
const historyImporting = imports.jobs.some(
(job) => job.status === "running" || job.status === "pending",
);
const recentCallSourceId = (recentCall?.references ?? []).find(
(reference) => reference.source_id,
)?.source_id;
@@ -616,72 +765,46 @@ export function ApplicationsHome({
: null;
const attention = callAttention(callEvents);
const attentionHost = attention ? callHostName(attention.host, locale) : null;
const baseActivity =
viewModel.activity.kind === "update"
? ("memory" as const)
: viewModel.activity.kind;
const activity = summaryUnavailable
? "unavailable"
: visible === null
? "loading"
: pinned === "call"
? "call"
: pinned === "update"
: pinned === "call"
? "call"
: pinned === "update"
? updateKind
: updateKind
? updateKind
: updateKind
? updateKind
: hasCall
? "call"
: callState === "unavailable"
? "unavailable"
: historyImporting
? "importing"
: callState === "loading"
? recentCall !== null
? "call"
: "loading"
: processing?.running
? "first-running"
: processing?.pending || processing?.waiting_for_model
? "first-queued"
: "empty";
: hasCall
? "call"
: callState === "unavailable"
? "unavailable"
: callState === "loading" && recentCall !== null
? "call"
: baseActivity;
const apps = imports.sources.filter(
(s) => s.authorized || s.detected === true || s.location_state === "found",
);
const stateLabel =
sedimentation?.enabled === true
? t("apps.running")
: sedimentation?.enabled === false
? t("apps.paused")
: t("apps.checkingStatus");
const stateDetail =
sedimentation?.enabled === false
? t("apps.stoppedNew")
: sedimentation?.enabled === true
? t("apps.organizingNew")
: t("apps.checkingProcessing");
const latestUpdatedAt =
latest === null || latest === undefined
? null
: "revision" in latest
? latest.updated_at
: latest.gmt_created === null
? null
: new Date(latest.gmt_created * 1000).toISOString();
const stateLabel = t(viewModel.processingLabel as never);
const stateDetail = t(viewModel.processingDetail as never);
const latestUpdatedAt = latest?.time ?? null;
const latestClock = latestUpdatedAt
? clockParts(latestUpdatedAt, locale)
: null;
const latestSourceIds =
latest === null || latest === undefined
? []
: "revision" in latest
? latest.source_ids
: latest.source
? [latest.source]
: [];
const latestSourceIds = latest?.sourceId ? [latest.sourceId] : [];
const latestSourceNames = latestSourceIds.map(
(id) =>
imports.sources.find((source) => source.source_id === id)?.display_name ??
id,
);
const time = latestClock?.clock ?? (latest ? t("apps.unknownTime") : null);
const checked = visible ? clockParts(visible.observed_at, locale) : null;
const checked = effectiveProjection
? clockParts(effectiveProjection.observed_at, locale)
: visible
? clockParts(visible.observed_at, locale)
: null;
// A failed read leaves the last health in place on purpose: clearing it would
// drop the instance it names, and that instance is what keeps memory writes
// and backfill receipts attached to the run that produced them. So the card
@@ -784,7 +907,7 @@ export function ApplicationsHome({
? tn("apps.enabledCount", authorizedSyncSources.length)
: syncApplyFailed
? t("apps.enableFailed")
: wantsSync || !visible
: wantsSync || viewModel.state === "loading"
? t("apps.checkingState")
: t("apps.notEnabled");
function toggleAll(next: boolean) {
@@ -832,29 +955,31 @@ export function ApplicationsHome({
</header>
<div className="r6-home-metrics">
<div role="status" aria-live="polite" aria-atomic="true">
<p>{t("apps.memories")}</p>
<p>{t(viewModel.countLabel)}</p>
<p className="r6-home-figure">
<strong>
{new Intl.NumberFormat(locale).format(
visible?.memory_count ?? 0,
)}
{viewModel.count === null
? "—"
: new Intl.NumberFormat(locale).format(viewModel.count)}
</strong>
{t("apps.unit") ? <i>{t("apps.unit")}</i> : null}
{viewModel.count !== null && t("apps.unit") ? (
<i>{t("apps.unit")}</i>
) : null}
</p>
<small>
{visible
{viewModel.state !== "loading"
? summaryStale
? t("apps.showingPrevious")
: t("apps.scope")
: t(viewModel.countDetail)
: summaryUnavailable
? t("apps.countWillUpdate")
: summaryFailures
? t("apps.startingRetry")
: t("apps.readingCount")}
: t(viewModel.countDetail)}
</small>
</div>
<div>
<p>{t("apps.processing")}</p>
<p>{t(viewModel.processingTitle)}</p>
<strong className="r6-home-word">{stateLabel}</strong>
<small>{stateDetail}</small>
</div>
@@ -862,16 +987,14 @@ export function ApplicationsHome({
<p>{t("apps.recentUpdate")}</p>
<strong className={time ? "r6-home-time" : "r6-home-word"}>
{time ??
(visible
? t("apps.notGenerated")
: t("apps.checkingRecent"))}
(viewModel.recentMetric.valueKey
? t(viewModel.recentMetric.valueKey)
: "")}
</strong>
<small>
{time
? `${latestClock?.today ? `${t("apps.today")}· ` : ""}${t("apps.saved")}`
: visible
? t("apps.separate")
: t("apps.recentWillUpdate")}
? `${latestClock?.today ? `${t("apps.today")}· ` : ""}${t(viewModel.recentMetric.detailKey)}`
: t(viewModel.recentMetric.detailKey)}
</small>
</div>
</div>
@@ -905,7 +1028,7 @@ export function ApplicationsHome({
? t("apps.recentCall")
: activity === "memory"
? t("apps.recentUpdate")
: t("apps.firstBatch")}
: t(viewModel.activity.headingKey)}
</p>
)}
<div>
@@ -935,7 +1058,13 @@ export function ApplicationsHome({
})()
) : null}
<section>
<h3>
<h3
className={
activity === "memory" && latest?.kind === "raw"
? "r6-home-raw-excerpt"
: undefined
}
>
{activity === "unavailable"
? callState === "unavailable"
? t("apps.callsUnavailable")
@@ -947,16 +1076,12 @@ export function ApplicationsHome({
locale,
)
: activity === "memory" && latest
? `${latest.content}${"truncated" in latest && latest.truncated ? "…" : ""}`
: activity === "importing"
? t("apps.importing")
: activity === "first-running"
? t("apps.firstRunning")
: activity === "first-queued"
? t("apps.firstQueued")
: activity === "loading"
? t("apps.readingActivity")
: t("apps.emptyMemories")}
? latest.emptyExcerpt
? t("apps.rawNoExcerpt")
: latest.excerpt
: viewModel.activity.titleKey
? t(viewModel.activity.titleKey)
: ""}
</h3>
<small>
{activity === "unavailable"
@@ -966,14 +1091,24 @@ export function ApplicationsHome({
: activity === "call" && recentCall
? `${recentCallHostName} · ${stamped(recentCall.occurred_at, locale, t)} · ${callRowDetail(recentCall, locale)}${callState === "incomplete" ? t("apps.incompleteCalls") : ""}`
: activity === "memory" && latest
? [time, latestSourceNames.join(", ")]
? (latest.kind === "raw"
? [
t("apps.rawConversation"),
latestSourceNames.join(", "),
projectionTimeBasisLabel(latest.timeBasis, t),
time,
]
: [
time,
latestSourceNames.join(", "),
projectionTimeBasisLabel(latest.timeBasis, t),
]
)
.filter(Boolean)
.join(" · ")
: activity === "importing"
? t("apps.savingConversations")
: activity === "loading"
? t("apps.recentWillUpdate")
: t("apps.waitingMemories")}
: viewModel.activity.detailKey
? t(viewModel.activity.detailKey)
: ""}
</small>
</section>
{(activity === "unavailable" ||
@@ -987,16 +1122,12 @@ export function ApplicationsHome({
setRevision((n) => n + 1);
setCallRevision((n) => n + 1);
}
: activity === "call"
? onCalls
: () => onMemories(latest?.memory_id)
: onCalls
}
>
{activity === "unavailable"
? t("apps.reread")
: activity === "call"
? t("apps.viewCalls")
: t("apps.viewMemory")}
: t("apps.viewCalls")}
{activity === "unavailable" ? null : (
<ArrowRight size={14} aria-hidden="true" />
)}
@@ -1049,8 +1180,12 @@ export function ApplicationsHome({
<dd>{syncState}</dd>
</div>
<div>
<dt>{t("apps.organizing")}</dt>
<dd>{stateLabel}</dd>
<dt>{t(viewModel.organizingTitle)}</dt>
<dd>{t(viewModel.organizingLabel as never)}</dd>
</div>
<div>
<dt>{t("apps.currentMode")}</dt>
<dd>{viewModel.currentModeLabel}</dd>
</div>
<div>
<dt>{t("apps.access")}</dt>
+10 -10
View File
@@ -159,7 +159,9 @@ it("traduce el historial en vivo sin cambiar nombres ni contenido importado", as
expect(
screen.getByRole("heading", { name: "Historial de uso" }),
).toBeTruthy();
expect(first.textContent).toBe("Cursor encontró 3 contenidos relacionados");
expect(first.textContent).toBe(
"Cursor encontró 1 memoria · 2 conversaciones originales",
);
fireEvent.click(first);
expect(
screen.getByRole("heading", { name: "Esta consulta de memorias" }),
@@ -364,7 +366,7 @@ it("shows exact identity, timestamp, result counts and metadata-only references"
).toBeTruthy();
expect(
within(detail).getByText((value) =>
value.includes("Cursor 查到 3 条相关内容"),
value.includes("Cursor 找到 1 条记忆 · 2 条原始会话"),
),
).toBeTruthy();
expect(within(detail).getAllByText("source-1").length).toBeGreaterThan(0);
@@ -465,13 +467,9 @@ it("keeps detail bound to event ID across refresh reordering, then removes missi
});
it.each([
["grounded", "查询成功", "直接调用(诊断) 查到 0 条相关内容"],
["grounded", "查询成功", "直接调用(诊断) 调用已完成"],
["empty", "查询成功,没有找到相关记忆", "直接调用(诊断) 没有查到相关记忆"],
[
"partial",
"查询成功,部分内容还在整理中",
"直接调用(诊断) 查到 0 条相关内容",
],
["partial", "查询成功,部分内容还在整理中", "直接调用(诊断) 调用已完成"],
[
"timed_out",
"查询超时,没有提供记忆",
@@ -546,11 +544,13 @@ it.each(["om_recall", "om_conversations", "om_ask", "om_matters"] as const)(
mount();
expect(
await screen.findByRole("button", {
name: "Cursor 查到 3 条相关内容",
name: "Cursor 找到 1 条记忆 · 2 条原始会话",
}),
).toBeTruthy();
fireEvent.click(
screen.getByRole("button", { name: "Cursor 查到 3 条相关内容" }),
screen.getByRole("button", {
name: "Cursor 找到 1 条记忆 · 2 条原始会话",
}),
);
expect(
within(screen.getByRole("article")).getByText("查记忆"),
@@ -0,0 +1,289 @@
// @vitest-environment jsdom
import type {
EmbeddingSwitchCurrent,
EmbeddingSwitchJob,
EmbeddingSwitchPlan,
} from "@one-memory/contracts";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
const cancelCurrentSwitch = vi.fn();
let current: EmbeddingSwitchCurrent | null = null;
vi.mock("./use-embedding-switch", () => ({
useEmbeddingSwitch: () => ({
current,
busy: null,
cancelCurrentSwitch,
}),
}));
vi.mock("./api", () => ({
deleteRetiredEmbeddingSpace: vi.fn(),
}));
import {
EmbeddingSwitchConfirmDialog,
EmbeddingSwitchGlobalStatus,
} from "./EmbeddingSwitchExperience";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
const NOW = "2026-11-03T09:00:00Z";
const space = (id: string | null, model: string) => ({
space_id: id,
model,
dims: model === "old-search" ? 512 : 1024,
collection_base: id ? "memory" : "",
vectors: id ? 8421 : null,
});
const plan: EmbeddingSwitchPlan = {
needed: true,
reason: "embedding_identity_changed",
plan_token: "token",
source: space("space-old", "old-search"),
target: space(null, "new-search"),
target_profile_id: "profile-1",
target_revision: 2,
target_provider_kind: "openai_compatible",
target_local: false,
sources: [
{
space_id: "space-old",
collection_base: "memory",
dims: 512,
vectors: 8421,
unmerged: null,
},
],
total_vectors: 8421,
users: 1,
graph_vectors: null,
estimate_s: 120,
requests: 43,
disk_bytes_needed: 72 * 1024 * 1024,
disk_bytes_free: 1024 * 1024 * 1024,
retention_days: 14,
blockers: [],
};
const job: EmbeddingSwitchJob = {
job_id: "job-1",
kind: "switch",
state: "reembedding",
source: space("space-old", "old-search"),
target: space("space-new", "new-search"),
stray_space_ids: [],
target_profile_id: "profile-1",
target_revision: 2,
requested_by: "app",
progress: {
phase: "vectors",
done: 4884,
total: 8421,
percent: 58,
embedded: 4884,
reused: 0,
kept: 0,
role: "memory",
source_index: 0,
sources: 1,
rate_per_s: 90,
eta_s: 52,
updated_at: NOW,
},
cancel_requested: false,
error_code: null,
attempt: 1,
created_at: NOW,
updated_at: NOW,
finished_at: null,
};
afterEach(() => {
cleanup();
current = null;
localStorage.removeItem(LANGUAGE_STORAGE_KEY);
vi.clearAllMocks();
});
it("switches Chinese, Spanish, and English without leaking Chinese", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-Hans");
const view = render(
<LanguageProvider>
<EmbeddingSwitchConfirmDialog
plan={{ ...plan, users: 2 }}
busy={false}
errorCode={null}
onCancel={vi.fn()}
onStart={vi.fn()}
/>
</LanguageProvider>,
);
await waitFor(() =>
expect(
screen.getByRole("heading", { name: "更新记忆搜索索引?" }),
).toBeTruthy(),
);
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
window.dispatchEvent(
new StorageEvent("storage", { key: LANGUAGE_STORAGE_KEY }),
);
await waitFor(() =>
expect(
screen.getByRole("heading", {
name: "¿Actualizar el índice de búsqueda de memorias?",
}),
).toBeTruthy(),
);
expect(view.container.textContent).not.toMatch(/[一-鿿]/);
expect(view.container.textContent).toContain("8421");
localStorage.setItem(LANGUAGE_STORAGE_KEY, "en");
window.dispatchEvent(
new StorageEvent("storage", { key: LANGUAGE_STORAGE_KEY }),
);
await waitFor(() =>
expect(
screen.getByRole("heading", {
name: "Update the memory search index?",
}),
).toBeTruthy(),
);
expect(view.container.textContent).not.toMatch(/[一-鿿]/);
expect(view.container.textContent).toContain("8,421");
});
it("explains the index update in user terms before exposing technical details", async () => {
const onStart = vi.fn();
render(
<EmbeddingSwitchConfirmDialog
plan={plan}
busy={false}
errorCode={null}
onCancel={vi.fn()}
onStart={onStart}
/>,
);
expect(
screen.getByRole("heading", { name: "更新记忆搜索索引?" }),
).toBeTruthy();
expect(screen.getByText("8,421 条")).toBeTruthy();
expect(screen.getByText("约 2 分钟")).toBeTruthy();
expect(screen.getByText(/失败或取消时仍使用原来的模型/)).toBeTruthy();
await userEvent.click(screen.getByText("查看技术详情"));
expect(screen.getByText("512 → 1024")).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "开始更新索引" }));
expect(onStart).toHaveBeenCalledTimes(1);
});
it("blocks start when Core reports a prerequisite", () => {
render(
<EmbeddingSwitchConfirmDialog
plan={{ ...plan, blockers: ["insufficient_disk_space"] }}
busy={false}
errorCode={null}
onCancel={vi.fn()}
onStart={vi.fn()}
/>,
);
expect(screen.getByText(/剩余空间不足/)).toBeTruthy();
expect(screen.getByRole("button", { name: "开始更新索引" })).toHaveProperty(
"disabled",
true,
);
});
it("keeps a running switch visible and cancellable outside settings", async () => {
current = {
schema_version: 1,
phase: "running",
writes_paused: true,
scan_state: "done",
scan_error: null,
job,
last_finished: null,
};
render(
<EmbeddingSwitchGlobalStatus
onConfigureSearch={vi.fn()}
onDiagnostics={vi.fn()}
/>,
);
expect(screen.getByText("正在更新记忆索引")).toBeTruthy();
expect(screen.getByRole("progressbar").getAttribute("aria-valuenow")).toBe(
"58",
);
await userEvent.click(screen.getByRole("button", { name: "取消更新" }));
expect(cancelCurrentSwitch).toHaveBeenCalledTimes(1);
});
it("shows startup index inspection without blocking the rest of the app", () => {
current = {
schema_version: 1,
phase: "scanning",
writes_paused: true,
scan_state: "scanning",
scan_error: null,
job: null,
last_finished: null,
};
render(
<EmbeddingSwitchGlobalStatus
onConfigureSearch={vi.fn()}
onDiagnostics={vi.fn()}
/>,
);
expect(screen.getByText("正在检查本机的记忆搜索索引")).toBeTruthy();
expect(screen.getByText(/搜索仍可使用/)).toBeTruthy();
});
it("explains rollback safety and gives recovery actions", async () => {
current = {
schema_version: 1,
phase: "running",
writes_paused: true,
scan_state: "done",
scan_error: null,
job,
last_finished: null,
};
const onConfigureSearch = vi.fn();
const onDiagnostics = vi.fn();
const view = render(
<EmbeddingSwitchGlobalStatus
onConfigureSearch={onConfigureSearch}
onDiagnostics={onDiagnostics}
/>,
);
current = {
schema_version: 1,
phase: "idle",
writes_paused: false,
scan_state: "done",
scan_error: null,
job: null,
last_finished: {
...job,
state: "rolled_back",
error_code: "embedding_service_failed",
progress: { ...job.progress, phase: "done" },
finished_at: NOW,
},
};
view.rerender(
<EmbeddingSwitchGlobalStatus
onConfigureSearch={onConfigureSearch}
onDiagnostics={onDiagnostics}
/>,
);
await waitFor(() => expect(screen.getByText(/记忆没有丢失/)).toBeTruthy());
await userEvent.click(screen.getByRole("button", { name: "检查连接" }));
expect(onConfigureSearch).toHaveBeenCalledTimes(1);
});
@@ -0,0 +1,480 @@
import type {
EmbeddingSwitchCurrent,
EmbeddingSwitchJob,
EmbeddingSwitchPlan,
} from "@one-memory/contracts";
import type { GuiLocale, MessageKey } from "@one-memory/i18n";
import { ChevronDown, CircleAlert, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { deleteRetiredEmbeddingSpace } from "./api";
import { useGuiLanguage } from "./gui-language";
import { R6Button } from "./R6Controls";
import { useEmbeddingSwitch } from "./use-embedding-switch";
import "./embedding-switch.css";
type Translator = (
key: MessageKey,
params?: Record<string, string | number>,
) => string;
function count(locale: GuiLocale, value: number | null | undefined) {
return new Intl.NumberFormat(locale).format(value ?? 0);
}
function duration(locale: GuiLocale, t: Translator, seconds: number | null) {
if (seconds === null) return t("embeddingSwitch.duration.unknown");
const value = Math.max(1, Math.round(seconds < 60 ? seconds : seconds / 60));
return t(
seconds < 60
? "embeddingSwitch.duration.seconds"
: "embeddingSwitch.duration.minutes",
{ count: count(locale, value) },
);
}
function size(locale: GuiLocale, bytes: number) {
const megabytes = bytes >= 1024 * 1024;
const value = Math.ceil(bytes / 1024 / (megabytes ? 1024 : 1));
return `${count(locale, value)} ${megabytes ? "MB" : "KB"}`;
}
const blockerKeys: Record<string, MessageKey> = {
embedding_probe_required: "embeddingSwitch.blocker.embedding_probe_required",
embedding_dimensions_unknown:
"embeddingSwitch.blocker.embedding_dimensions_unknown",
embedding_switch_requires_migration:
"embeddingSwitch.blocker.embedding_switch_requires_migration",
sdk_reembed_unavailable: "embeddingSwitch.blocker.sdk_reembed_unavailable",
insufficient_disk_space: "embeddingSwitch.blocker.insufficient_disk_space",
embedding_switch_running: "embeddingSwitch.blocker.embedding_switch_running",
memory_lifecycle_busy: "embeddingSwitch.blocker.memory_lifecycle_busy",
};
const failureKeys: Record<string, MessageKey> = {
writes_did_not_drain: "embeddingSwitch.failure.writes_did_not_drain",
embedding_service_failed: "embeddingSwitch.failure.embedding_service_failed",
embedding_dimensions_mismatch:
"embeddingSwitch.failure.embedding_dimensions_mismatch",
embedding_verification_failed:
"embeddingSwitch.failure.embedding_verification_failed",
target_profile_changed: "embeddingSwitch.failure.target_profile_changed",
embedding_switch_cancelled:
"embeddingSwitch.failure.embedding_switch_cancelled",
model_secret_unavailable: "embeddingSwitch.failure.model_secret_unavailable",
secure_storage_unavailable:
"embeddingSwitch.failure.secure_storage_unavailable",
embedding_switch_clients_busy:
"embeddingSwitch.failure.embedding_switch_clients_busy",
};
export function EmbeddingSwitchConfirmDialog({
plan,
busy,
errorCode,
onCancel,
onStart,
}: {
plan: EmbeddingSwitchPlan;
busy: boolean;
errorCode: string | null;
onCancel(): void;
onStart(): void;
}) {
const { locale, t } = useGuiLanguage();
const panel = useRef<HTMLElement>(null);
const blockers = plan.blockers.map((code) =>
t(blockerKeys[code] ?? "embeddingSwitch.blocker.default"),
);
useEffect(() => {
const opener =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
panel.current
?.querySelector<HTMLButtonElement>("button:not(:disabled)")
?.focus();
return () => {
if (opener?.isConnected) opener.focus();
};
}, []);
return (
<div
className="r6-settings-dialog-layer embedding-switch-dialog-layer"
role="presentation"
>
<section
ref={panel}
className="r6-settings-dialog embedding-switch-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="embedding-switch-title"
onKeyDown={(event) => {
if (event.key === "Escape" && !busy) onCancel();
if (event.key !== "Tab" || !panel.current) return;
const controls = [
...panel.current.querySelectorAll<HTMLElement>(
"button:not(:disabled), summary, input:not(:disabled), select:not(:disabled)",
),
];
const first = controls[0];
const last = controls.at(-1);
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}}
>
<header>
<div>
<h2 id="embedding-switch-title">
{t("embeddingSwitch.confirm.title")}
</h2>
<p>{t("embeddingSwitch.confirm.description")}</p>
</div>
</header>
<dl className="embedding-switch-impact">
<div>
<dt>{t("embeddingSwitch.impact.memories")}</dt>
<dd>
{t("embeddingSwitch.impact.memoryCount", {
count: count(locale, plan.total_vectors),
})}
{plan.users && plan.users > 1
? t("embeddingSwitch.impact.userCount", {
count: count(locale, plan.users),
})
: ""}
</dd>
</div>
<div>
<dt>{t("embeddingSwitch.impact.duration")}</dt>
<dd>{duration(locale, t, plan.estimate_s)}</dd>
</div>
<div>
<dt>{t("embeddingSwitch.impact.requests")}</dt>
<dd>
{plan.target_local
? t("embeddingSwitch.requests.none")
: plan.requests === null
? t("embeddingSwitch.requests.billed")
: t("embeddingSwitch.requests.approx", {
count: count(locale, plan.requests),
})}
</dd>
</div>
</dl>
<div className="embedding-switch-safety">
<strong>{t("embeddingSwitch.safety.searchTitle")}</strong>
<p>{t("embeddingSwitch.safety.searchBody")}</p>
<strong>{t("embeddingSwitch.safety.dataTitle")}</strong>
<p>{t("embeddingSwitch.safety.dataBody")}</p>
</div>
<details className="embedding-switch-details">
<summary>
<ChevronDown size={14} aria-hidden="true" />
{t("embeddingSwitch.details.title")}
</summary>
<dl>
<div>
<dt>{t("embeddingSwitch.details.currentModel")}</dt>
<dd>
{plan.source.model ?? t("embeddingSwitch.details.legacyIndex")}
</dd>
</div>
<div>
<dt>{t("embeddingSwitch.details.newModel")}</dt>
<dd>
{plan.target.model ?? t("embeddingSwitch.details.pending")}
</dd>
</div>
<div>
<dt>{t("embeddingSwitch.details.dimensions")}</dt>
<dd>
{plan.source.dims ?? t("embeddingSwitch.details.unknown")} →{" "}
{plan.target.dims ?? t("embeddingSwitch.details.unknown")}
</dd>
</div>
<div>
<dt>{t("embeddingSwitch.details.disk")}</dt>
<dd>{size(locale, plan.disk_bytes_needed)}</dd>
</div>
<div>
<dt>{t("embeddingSwitch.details.retention")}</dt>
<dd>
{t("embeddingSwitch.details.days", {
count: count(locale, plan.retention_days),
})}
</dd>
</div>
</dl>
</details>
{blockers.length > 0 ? (
<div className="embedding-switch-alert" role="alert">
<CircleAlert size={16} aria-hidden="true" />
<span>{blockers.join(" ")}</span>
</div>
) : null}
{errorCode ? (
<div className="embedding-switch-alert" role="alert">
<CircleAlert size={16} aria-hidden="true" />
<span>
{errorCode === "embedding_switch_plan_stale"
? t("embeddingSwitch.error.planStale")
: t("embeddingSwitch.error.startFailed")}
</span>
</div>
) : null}
<footer>
<R6Button disabled={busy} onClick={onCancel}>
{t("embeddingSwitch.action.notNow")}
</R6Button>
<R6Button
variant="primary"
pending={busy}
disabled={blockers.length > 0}
onClick={onStart}
>
{t("embeddingSwitch.action.start")}
</R6Button>
</footer>
</section>
</div>
);
}
function jobTitle(t: Translator, job: EmbeddingSwitchJob) {
if (job.kind === "merge") return t("embeddingSwitch.job.merge");
if (job.state === "preparing") return t("embeddingSwitch.job.preparing");
if (job.state === "reembedding") return t("embeddingSwitch.job.reembedding");
if (job.state === "verifying") return t("embeddingSwitch.job.verifying");
if (job.state === "committing") return t("embeddingSwitch.job.committing");
if (job.state === "failed") return t("embeddingSwitch.job.failed");
return t("embeddingSwitch.job.default");
}
function canCancel(job: EmbeddingSwitchJob) {
return ["preparing", "reembedding", "verifying"].includes(job.state);
}
function ActiveSwitchBanner({
current,
onCancel,
cancelling,
}: {
current: EmbeddingSwitchCurrent;
onCancel(): void;
cancelling: boolean;
}) {
const { locale, t } = useGuiLanguage();
const job = current.job;
if (!job && current.phase !== "scanning") return null;
const percent = job?.progress.percent ?? 0;
return (
<aside className="embedding-switch-strip" aria-live="polite">
<div className="embedding-switch-strip-copy">
<strong>
{job ? jobTitle(t, job) : t("embeddingSwitch.job.scanning")}
</strong>
<span>
{job?.state === "failed"
? t("embeddingSwitch.job.failedSafety")
: job
? `${new Intl.NumberFormat(locale, {
style: "percent",
maximumFractionDigits: 0,
}).format(percent / 100)}${
job.progress.eta_s === null
? ""
: ` · ${duration(locale, t, job.progress.eta_s)}`
}`
: t("embeddingSwitch.job.scanPaused")}
</span>
</div>
{job ? (
<div
className="embedding-switch-progress"
role="progressbar"
aria-label={t("embeddingSwitch.progress.aria")}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(percent)}
>
<span style={{ width: `${Math.max(2, percent)}%` }} />
</div>
) : null}
{job && canCancel(job) ? (
<R6Button variant="quiet" pending={cancelling} onClick={onCancel}>
{t("embeddingSwitch.action.cancel")}
</R6Button>
) : null}
</aside>
);
}
export function EmbeddingSwitchGlobalStatus({
onConfigureSearch,
onDiagnostics,
}: {
onConfigureSearch(): void;
onDiagnostics(): void;
}) {
const { t } = useGuiLanguage();
const controller = useEmbeddingSwitch();
const [recent, setRecent] = useState<EmbeddingSwitchJob | null>(null);
const [cleaning, setCleaning] = useState(false);
const [cleanupError, setCleanupError] = useState(false);
const previousJob = useRef<string | null>(null);
const alive = useRef(true);
useEffect(
() => () => {
alive.current = false;
},
[],
);
useEffect(() => {
const open = controller.current?.job?.job_id ?? null;
if (
previousJob.current &&
!open &&
controller.current?.last_finished?.job_id === previousJob.current
) {
setRecent(controller.current.last_finished);
}
previousJob.current = open;
}, [controller.current]);
return (
<>
{controller.current ? (
<ActiveSwitchBanner
current={controller.current}
cancelling={controller.busy === "cancel"}
onCancel={() => void controller.cancelCurrentSwitch()}
/>
) : null}
{controller.refreshFailed && controller.supported === true ? (
<aside className="embedding-switch-outcome is-error" role="status">
<div>
<strong>{t("embeddingSwitch.refresh.title")}</strong>
<span>{t("embeddingSwitch.refresh.body")}</span>
</div>
<R6Button variant="quiet" onClick={() => void controller.refresh()}>
{t("embeddingSwitch.action.refresh")}
</R6Button>
</aside>
) : null}
{controller.current &&
["failed", "unavailable"].includes(controller.current.scan_state) ? (
<aside className="embedding-switch-outcome is-error" role="alert">
<div>
<strong>{t("embeddingSwitch.scan.title")}</strong>
<span>
{controller.current.writes_paused
? t("embeddingSwitch.scan.paused")
: t("embeddingSwitch.scan.recovered")}
</span>
</div>
<R6Button variant="quiet" onClick={onDiagnostics}>
{t("embeddingSwitch.action.diagnostics")}
</R6Button>
</aside>
) : null}
{recent?.state === "done" ? (
<aside
className="embedding-switch-outcome"
role="status"
aria-live="polite"
>
<div>
<strong>{t("embeddingSwitch.success.title")}</strong>
<span>{t("embeddingSwitch.success.body")}</span>
</div>
<div className="embedding-switch-outcome-actions">
{recent.source.space_id ? (
<R6Button
variant="quiet"
pending={cleaning}
onClick={() => {
if (!recent.source.space_id) return;
setCleaning(true);
setCleanupError(false);
void deleteRetiredEmbeddingSpace(recent.source.space_id)
.then(() => {
if (alive.current) setRecent(null);
})
.catch(() => {
if (alive.current) setCleanupError(true);
})
.finally(() => {
if (alive.current) setCleaning(false);
});
}}
>
{t("embeddingSwitch.action.cleanup")}
</R6Button>
) : null}
<R6Button
variant="quiet"
aria-label={t("embeddingSwitch.action.closeSuccess")}
onClick={() => setRecent(null)}
>
<X size={16} aria-hidden="true" />
</R6Button>
</div>
</aside>
) : null}
{cleanupError ? (
<p className="embedding-switch-cleanup-error" role="alert">
{t("embeddingSwitch.cleanup.failed")}
</p>
) : null}
{recent?.state === "rolled_back" ? (
<aside
className="embedding-switch-outcome is-error"
role="alert"
aria-live="assertive"
>
<div>
<strong>{t("embeddingSwitch.rollback.title")}</strong>
<span>
{failureKeys[recent.error_code ?? ""]
? t("embeddingSwitch.rollback.reason", {
reason: t(failureKeys[recent.error_code ?? ""]),
safety: t("embeddingSwitch.rollback.safety"),
})
: t("embeddingSwitch.rollback.safety")}
</span>
</div>
<div className="embedding-switch-outcome-actions">
<R6Button variant="quiet" onClick={onDiagnostics}>
{t("embeddingSwitch.action.diagnostics")}
</R6Button>
<R6Button variant="quiet" onClick={onConfigureSearch}>
{t("embeddingSwitch.action.checkConnection")}
</R6Button>
<R6Button
variant="quiet"
aria-label={t("embeddingSwitch.action.closeFailure")}
onClick={() => setRecent(null)}
>
<X size={16} aria-hidden="true" />
</R6Button>
</div>
</aside>
) : null}
</>
);
}
@@ -239,7 +239,7 @@ it("emits controlled connection drafts only and passes continuation without comp
screen.getByRole<HTMLInputElement>("checkbox", { name: "一键接入应用" })
.checked,
).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "配置模型,沉淀记忆" }));
fireEvent.click(screen.getByRole("button", { name: "选择记忆模式" }));
expect(next).toHaveBeenCalledWith("settings");
});
@@ -379,8 +379,8 @@ it("uses user-facing connection confirmation copy without implementation narrati
expect(
screen.queryByText(/选择草稿|按各应用回执|本页任务控制尚未接通/),
).toBeNull();
expect(screen.getByText(/不会自动开放应用读取权限/)).toBeTruthy();
expect(screen.getByText(/配置模型后,才会开始生成记忆/)).toBeTruthy();
expect(screen.getByText(/下一步选择记忆模式/)).toBeTruthy();
expect(screen.getByText(/之后仍可在设置中更改/)).toBeTruthy();
});
it.each(["paused", "failed", "cancelled", "interrupted"] as const)(
@@ -147,7 +147,7 @@ it("explains that one large harness limits every first-import source to three da
it("continues to step3 while sample GET is pending without calling completion APIs", () => {
api.receipt.mockReturnValue(new Promise(() => {}));
const { props } = setup();
fireEvent.click(screen.getByRole("button", { name: "配置模型,沉淀记忆" }));
fireEvent.click(screen.getByRole("button", { name: "选择记忆模式" }));
expect(props.onContinue).toHaveBeenCalledWith("settings");
expect(screen.queryByText(/^已生成.*记忆/)).toBeNull();
});
+2 -4
View File
@@ -373,7 +373,7 @@ export function FirstImportReview({
disabled={busy}
onClick={() => leave("settings")}
>
{text("配置模型,沉淀记忆")}
{text("选择记忆模式")}
</R6Button>
)}
</div>
@@ -393,9 +393,7 @@ export function FirstImportReview({
)}
</p>
<p className="first-import-r6-subintro">
{text(
"导入不会自动开放应用读取权限。配置模型后,才会开始生成记忆。",
)}
{text("下一步选择记忆模式;之后仍可在设置中更改。")}
</p>
{historyWindowLimited && (
<p className="first-import-r6-history-window" role="status">
@@ -0,0 +1,345 @@
import type { ProviderKind } from "@one-memory/contracts";
import { ChevronDown, ChevronUp } from "lucide-react";
import type { ReactNode, Ref } from "react";
import { OptionMenu } from "./OptionMenu";
import { PROVIDER_PRESETS } from "./provider-presets";
export interface GenerationModelValues {
provider_kind: ProviderKind;
model: string;
base_url: string;
timeout_seconds: string;
max_concurrency: string;
context_window: string;
disable_thinking: boolean;
}
export type GenerationModelField = keyof GenerationModelValues | "api_key";
export type GenerationModelErrors = Partial<
Record<GenerationModelField, string>
>;
export const GENERATION_PROTOCOLS: readonly {
value: ProviderKind;
label: string;
supported: boolean;
}[] = [
{
value: "openai_compatible",
label: "OpenAI-compatible",
supported: true,
},
{ value: "ollama", label: "Ollama(本机)", supported: true },
{ value: "anthropic", label: "Anthropic(暂不支持)", supported: false },
{ value: "hy_default", label: "HY 托管(当前不可用)", supported: false },
];
export function presetForGeneration(profile: {
provider_kind: string;
base_url: string | null;
}): string {
return (
PROVIDER_PRESETS.find(
(preset) =>
preset.provider === profile.provider_kind &&
endpointKey(preset.baseUrl) === endpointKey(profile.base_url ?? ""),
)?.id ?? "custom"
);
}
export function generationProviderLabel(provider: ProviderKind): string {
return (
GENERATION_PROTOCOLS.find((item) => item.value === provider)?.label ??
provider
);
}
export function generationSummary(
presetId: string,
provider: ProviderKind,
model: string,
text: (value: string) => string,
): string {
const preset = PROVIDER_PRESETS.find((item) => item.id === presetId);
return `${preset ? text(preset.label) : text(generationProviderLabel(provider))} · ${
model.trim() || text("模型待填写")
}`;
}
export function validGenerationEndpoint(value: string): boolean {
try {
const url = new URL(value);
return (
["http:", "https:"].includes(url.protocol) &&
!!url.hostname &&
!url.username &&
!url.password
);
} catch {
return false;
}
}
export function validateGenerationModel(
values: GenerationModelValues,
options: {
apiKey: string;
keyRequired: boolean;
requireRuntimeValues?: boolean;
},
): GenerationModelErrors {
const errors: GenerationModelErrors = {};
if (!values.model.trim()) errors.model = "请填写实际可用的模型 ID。";
if (!validGenerationEndpoint(values.base_url))
errors.base_url = "请填写有效的模型接口地址。";
if (
options.apiKey.trim() &&
/^(?:[a-z][a-z\d+.-]*:\/\/|www\.|\/\/)/i.test(options.apiKey.trim())
)
errors.api_key = "这里需要 API Key,不是网址。请填写服务方提供的密钥。";
else if (options.keyRequired && !options.apiKey.trim())
errors.api_key = "请填写 API Key。";
const integer = (
key: "timeout_seconds" | "max_concurrency" | "context_window",
min: number,
max: number,
) => {
const raw = values[key].trim();
if (!raw && !options.requireRuntimeValues) return;
const value = Number(raw);
if (!raw || !Number.isInteger(value) || value < min || value > max)
errors[key] = "onboardingIntegerRange";
};
integer("timeout_seconds", 1, 600);
integer("max_concurrency", 1, 32);
if (values.context_window.trim()) integer("context_window", 1024, 10_000_000);
return errors;
}
export function ProviderPresetField({
id,
labelledBy,
ariaLabel,
describedBy,
value,
disabled,
text,
includePlaceholder = false,
onChange,
}: {
id?: string;
labelledBy?: string;
ariaLabel?: string;
describedBy?: string;
value: string;
disabled?: boolean;
text: (value: string) => string;
includePlaceholder?: boolean;
onChange(value: string): void;
}) {
return (
<OptionMenu
id={id}
labelledBy={labelledBy}
ariaLabel={ariaLabel}
describedBy={describedBy}
value={value}
disabled={disabled}
options={[
...(includePlaceholder && !value
? [{ value: "", label: text("选择服务商"), disabled: true }]
: []),
...PROVIDER_PRESETS.map((preset) => ({
value: preset.id,
label: text(preset.label),
})),
{ value: "custom", label: text("自定义兼容服务") },
]}
onChange={onChange}
/>
);
}
export function GenerationTechnicalDisclosure({
open,
disabled,
controls,
text,
onChange,
children,
}: {
open: boolean;
disabled?: boolean;
controls: string;
text: (value: string) => string;
onChange(open: boolean): void;
children: ReactNode;
}) {
return (
<section className="generation-technical-disclosure">
<button
type="button"
className="generation-technical-toggle"
aria-expanded={open}
aria-controls={controls}
disabled={disabled}
onClick={() => onChange(!open)}
>
{text("配置详情与高级设置")}
{open ? (
<ChevronUp size={16} aria-hidden="true" />
) : (
<ChevronDown size={16} aria-hidden="true" />
)}
</button>
<div id={controls} className="generation-technical-fields" hidden={!open}>
{children}
</div>
</section>
);
}
export function GenerationTechnicalFields({
idPrefix,
values,
errors = {},
disabled,
text,
modelRef,
onChange,
}: {
idPrefix: string;
values: GenerationModelValues;
errors?: GenerationModelErrors;
disabled?: boolean;
text: (value: string) => string;
modelRef?: Ref<HTMLInputElement>;
onChange(patch: Partial<GenerationModelValues>): void;
}) {
const field = (
key:
| "model"
| "base_url"
| "timeout_seconds"
| "max_concurrency"
| "context_window",
label: string,
options: {
type?: string;
min?: number;
max?: number;
placeholder?: string;
ref?: Ref<HTMLInputElement>;
} = {},
) => {
const error = errors[key];
const inputId = `${idPrefix}-${key}`;
return (
<label className="generation-model-field" htmlFor={inputId}>
<span>{text(label)}</span>
<input
ref={options.ref}
id={inputId}
name={key}
aria-label={text(label)}
type={options.type ?? "text"}
inputMode={options.type === "url" ? "url" : undefined}
autoComplete="off"
spellCheck={false}
value={values[key]}
disabled={disabled}
min={options.min}
max={options.max}
maxLength={key === "model" ? 256 : undefined}
placeholder={
options.placeholder ? text(options.placeholder) : undefined
}
aria-invalid={!!error}
aria-describedby={error ? `${inputId}-error` : undefined}
onChange={(event) => onChange({ [key]: event.target.value })}
/>
{error ? (
<small id={`${inputId}-error`} className="generation-model-error">
{text(error)}
</small>
) : null}
</label>
);
};
return (
<>
<label
className="generation-model-field"
htmlFor={`${idPrefix}-protocol`}
>
<span>{text("协议")}</span>
<select
id={`${idPrefix}-protocol`}
name="provider"
aria-label={text("协议")}
value={values.provider_kind}
disabled={disabled}
onChange={(event) =>
onChange({ provider_kind: event.target.value as ProviderKind })
}
>
{GENERATION_PROTOCOLS.map((protocol) => (
<option
key={protocol.value}
value={protocol.value}
disabled={!protocol.supported}
>
{text(protocol.label)}
</option>
))}
</select>
</label>
{field("model", "生成模型名", {
placeholder: "例如:gpt-4o-mini…",
ref: modelRef,
})}
{field("base_url", "Base URL", { type: "url" })}
<div className="generation-runtime-fields">
{field("timeout_seconds", "超时(秒)", {
type: "number",
min: 1,
max: 600,
placeholder: "自动 / 可选",
})}
{field("max_concurrency", "最大并发", {
type: "number",
min: 1,
max: 32,
placeholder: "自动 / 可选",
})}
{field("context_window", "上下文窗口", {
type: "number",
min: 1024,
max: 10_000_000,
placeholder: "自动 / 可选",
})}
</div>
<small className="generation-runtime-note">
{text("留空使用 Core 默认值。")}
</small>
<label className="generation-thinking">
<input
name="disable-thinking"
type="checkbox"
checked={values.disable_thinking}
disabled={disabled}
onChange={(event) =>
onChange({ disable_thinking: event.target.checked })
}
/>
{text("关闭思考模式")}
</label>
</>
);
}
function endpointKey(value: string): string {
return value.trim().replace(/\/+$/, "").toLowerCase();
}
@@ -153,6 +153,58 @@ describe("ImportProgressStrip", () => {
expect(container.textContent).toBe("");
});
it("uses raw-saving copy and never offers model setup in lite mode", () => {
render(
<ImportProgressStrip
jobs={[job()]}
sources={sources}
backfilling={false}
onOpenSources={vi.fn()}
onConfigureModel={vi.fn()}
modeCapabilities={{
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
}}
arrival={{
importing: true,
importedMessages: 3,
importedConversations: 1,
phase: "model-required",
}}
/>,
);
expect(screen.getByRole("status").textContent).toContain(
"正在保存原始对话",
);
expect(screen.queryByRole("button", { name: "设置模型" })).toBeNull();
});
it("does not keep a lite progress strip mounted without active work", () => {
const { container } = render(
<ImportProgressStrip
jobs={[]}
sources={sources}
backfilling={false}
onOpenSources={vi.fn()}
modeCapabilities={{
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
}}
arrival={{
importing: false,
importedMessages: 3,
importedConversations: 1,
phase: "stopped",
}}
/>,
);
expect(container.textContent).toBe("");
});
it("shows one plain sentence for first history import without details", () => {
render(
<ImportProgressStrip
+46 -29
View File
@@ -1,4 +1,5 @@
import type {
HomeProjectionCapabilities,
ImportIssue,
ImportJob,
SourceResponse,
@@ -68,6 +69,7 @@ export interface ImportProgressStripProps {
showSourceLink?: boolean;
showProgressDetails?: boolean;
userFacing?: boolean;
modeCapabilities?: HomeProjectionCapabilities | null;
}
/** Named sources and confirmed counts, without claiming cached jobs are live. */
@@ -85,6 +87,7 @@ export function ImportProgressStrip({
showSourceLink = true,
showProgressDetails = true,
userFacing = false,
modeCapabilities,
}: ImportProgressStripProps) {
const { t, tn } = useGuiLanguage();
const [refreshing, setRefreshing] = useState(false);
@@ -212,7 +215,13 @@ export function ImportProgressStrip({
),
);
const currentArrival = arrival
? resolveMemoryArrival({ arrival, processing: null, sources, loaded: true })
? resolveMemoryArrival({
arrival,
processing: null,
sources,
loaded: true,
capabilities: modeCapabilities,
})
: null;
const needsAction =
currentArrival &&
@@ -253,11 +262,14 @@ export function ImportProgressStrip({
(sum, source) => sum + source.imported_conversations,
0,
);
const message = firstImport
? tn("importUserProgress", imported)
: currentArrival?.phase === "model-required"
? t("importUserModel")
: t("importUserIncomplete");
const message =
modeCapabilities?.generation_required === false
? t("apps.rawSaving")
: firstImport
? tn("importUserProgress", imported)
: currentArrival?.phase === "model-required"
? t("importUserModel")
: t("importUserIncomplete");
return (
<div
className="import-strip"
@@ -272,16 +284,18 @@ export function ImportProgressStrip({
)}
<strong>{message}</strong>
</div>
{!firstImport && onConfigureModel && (
<button
type="button"
className="secondary"
onClick={onConfigureModel}
>
<Settings2 size={15} aria-hidden="true" />
{t("importUserSettings")}
</button>
)}
{!firstImport &&
modeCapabilities?.generation_required !== false &&
onConfigureModel && (
<button
type="button"
className="secondary"
onClick={onConfigureModel}
>
<Settings2 size={15} aria-hidden="true" />
{t("importUserSettings")}
</button>
)}
</div>
</div>
);
@@ -322,19 +336,21 @@ export function ImportProgressStrip({
? updating
? "已保存内容保留,正在核对进度"
: "进度待更新"
: currentArrival && needsAction
? currentArrival.phase === "model-required"
? "待继续"
: memoryArrivalTitle(currentArrival)
: generating
? "正在沉淀记忆"
: importing || currentArrival?.phase === "importing"
? running.length > 0 && running.every(isSyncJob)
? "正在检查新增对话"
: "正在导入对话"
: backfilling
? "正在准备整理"
: "待继续";
: modeCapabilities?.generation_required === false
? t("apps.rawSaving")
: currentArrival && needsAction
? currentArrival.phase === "model-required"
? "待继续"
: memoryArrivalTitle(currentArrival)
: generating
? "正在沉淀记忆"
: importing || currentArrival?.phase === "importing"
? running.length > 0 && running.every(isSyncJob)
? "正在检查新增对话"
: "正在导入对话"
: backfilling
? "正在准备整理"
: "待继续";
return (
<div
@@ -645,6 +661,7 @@ export function ImportProgressStrip({
</button>
)}
{!stale &&
modeCapabilities?.generation_required !== false &&
currentArrival?.phase === "model-required" &&
onConfigureModel && (
<button type="button" className="quiet" onClick={onConfigureModel}>
+111 -1
View File
@@ -46,6 +46,7 @@ const api = vi.hoisted(() => ({
vi.mock("./api", () => api);
const REQUEST_ID = "11111111-2222-4333-8444-555555555555";
const OLD_REQUEST_ID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
const addition: MemoryAddReceipt = {
evidence_id: "evidence-1",
text: "项目周会不排在周五。",
@@ -122,13 +123,14 @@ it("explains source verification failure without retrying or claiming success",
expect(screen.queryByRole("button", { name: "重新整理" })).toBeNull();
});
function page() {
function page(rawOnly = false) {
const onSaved = vi.fn(),
onOpenMemory = vi.fn(),
onConfigureModel = vi.fn(),
onPendingChange = vi.fn();
const view = render(
<MemoryAddComposer
rawOnly={rawOnly}
{...{ onSaved, onOpenMemory, onConfigureModel, onPendingChange }}
/>,
);
@@ -198,6 +200,114 @@ it("saves from an empty library with the linked-app scope by default", async ()
expect(screen.queryByRole("button", { name: "查看记忆" })).toBeNull();
});
it("saves a lite manual addition as original content without model UI", async () => {
api.addMemory.mockResolvedValue({
...addition,
state: "no_memories",
});
const callbacks = page(true);
await userEvent.click(screen.getByRole("button", { name: "手动添加" }));
expect(screen.getByRole("dialog", { name: "手动添加" })).toBeTruthy();
expect(screen.queryByText(/整理模型:/)).toBeNull();
expect(api.getModelProfiles).not.toHaveBeenCalled();
await userEvent.type(
screen.getByRole("textbox", { name: "记下这件事" }),
addition.text,
);
await userEvent.click(screen.getByRole("button", { name: "保存" }));
expect(await screen.findByText("已保存原文")).toBeTruthy();
expect(callbacks.onSaved).toHaveBeenCalledOnce();
expect(
callbacks.onPendingChange.mock.calls.some(([value]) => value === null),
).toBe(true);
expect(screen.queryByRole("dialog", { name: "手动添加" })).toBeNull();
});
it("refreshes the lite raw page when an asynchronous manual save completes", async () => {
api.addMemory.mockResolvedValue({
...addition,
state: "running",
});
api.getMemoryAddStatus.mockResolvedValue({
...addition,
state: "no_memories",
});
const callbacks = page(true);
await userEvent.click(screen.getByRole("button", { name: "手动添加" }));
await userEvent.type(screen.getByRole("textbox"), addition.text);
await userEvent.click(screen.getByRole("button", { name: "保存" }));
await waitFor(() => expect(callbacks.onSaved).toHaveBeenCalledTimes(2), {
timeout: 2_500,
});
expect(screen.getByText("已保存原文")).toBeTruthy();
expect(callbacks.onPendingChange).toHaveBeenLastCalledWith(null);
});
it("does not treat an older completed addition as a newly saved raw turn", async () => {
localStorage.setItem(
RECENT_ADDITIONS_KEY,
JSON.stringify([REQUEST_ID, OLD_REQUEST_ID]),
);
let currentState: MemoryAddReceipt["state"] = "running";
const calls = new Map<string, number>();
api.getMemoryAddStatus.mockImplementation(async (requestId: string) => {
calls.set(requestId, (calls.get(requestId) ?? 0) + 1);
return requestId === OLD_REQUEST_ID
? {
...addition,
evidence_id: "old-evidence",
state: "no_memories",
}
: { ...addition, state: currentState };
});
const callbacks = page(true);
await waitFor(
() => expect(calls.get(REQUEST_ID) ?? 0).toBeGreaterThanOrEqual(2),
{ timeout: 2_500 },
);
expect(callbacks.onSaved).not.toHaveBeenCalled();
currentState = "no_memories";
await waitFor(() => expect(callbacks.onSaved).toHaveBeenCalledOnce(), {
timeout: 2_500,
});
});
it("keeps a failed lite raw write visible after the composer closes", async () => {
api.addMemory.mockResolvedValue({
...addition,
state: "running",
});
api.getMemoryAddStatus.mockResolvedValue({
...addition,
state: "failed",
});
const callbacks = page(true);
await userEvent.click(screen.getByRole("button", { name: "手动添加" }));
await userEvent.type(screen.getByRole("textbox"), addition.text);
await userEvent.click(screen.getByRole("button", { name: "保存" }));
expect(screen.queryByText("已保存原文")).toBeNull();
await waitFor(
() =>
expect(callbacks.onPendingChange).toHaveBeenLastCalledWith({
evidenceId: addition.evidence_id,
text: addition.text,
state: "failed",
}),
{ timeout: 2_500 },
);
await waitFor(() =>
expect(screen.getByText("原话已保存,整理未完成")).toBeTruthy(),
);
});
it("preserves draft and request identity after an uncertain save", async () => {
api.addMemory
.mockRejectedValueOnce(new Error("timeout"))
+118 -30
View File
@@ -103,12 +103,14 @@ export function MemoryAddComposer({
onConfigureModel,
onPendingChange,
suspended = false,
rawOnly = false,
}: {
onSaved(): void;
onOpenMemory(id: string): void;
onConfigureModel(): void;
onPendingChange?(addition: PendingMemoryAdd | null): void;
suspended?: boolean;
rawOnly?: boolean;
}) {
const { text: copy } = useMemoryLanguage();
const [open, setOpen] = useState(false);
@@ -128,6 +130,7 @@ export function MemoryAddComposer({
const additionsRequest = useRef(0);
const intent = useRef<{ text: string; requestId: string } | null>(null);
const completed = useRef(new Set<string>());
const watched = useRef(new Set<string>());
const lastSaved = useRef<string | null>(null);
const onSavedRef = useRef(onSaved);
onSavedRef.current = onSaved;
@@ -156,34 +159,72 @@ export function MemoryAddComposer({
}, []);
const refreshAdditions = useCallback(
async (failure: string, notifyResults = false) => {
async (
failure: string,
notifyResults = false,
allowWhileClosed = false,
) => {
const request = ++additionsRequest.current;
try {
const value = await loadRecentAdditions();
if (!isOpen.current || request !== additionsRequest.current) return;
if (
(!allowWhileClosed && !isOpen.current) ||
request !== additionsRequest.current
)
return;
setAdditions(value);
reportPending(value);
if (!notifyResults) return;
if (!notifyResults) {
for (const { receipt } of value) {
if (
receipt.state === "ready" ||
(rawOnly && receipt.state === "no_memories")
)
completed.current.add(receipt.evidence_id);
else if (receipt.state === "pending" || receipt.state === "running")
watched.current.add(receipt.evidence_id);
}
return;
}
const latest = value.find(
(item) => item.receipt.evidence_id === lastSaved.current,
);
if (latest) setNotice(states[latest.receipt.state]);
if (latest) {
setNotice(
rawOnly && latest.receipt.state === "no_memories"
? "已保存原文"
: states[latest.receipt.state],
);
if (rawOnly && latest.receipt.state === "failed")
setSavedToast(states.failed);
}
const newResults = value.filter(
({ receipt }) =>
receipt.state === "ready" &&
(receipt.state === "ready" ||
(rawOnly && receipt.state === "no_memories")) &&
watched.current.has(receipt.evidence_id) &&
!completed.current.has(receipt.evidence_id),
);
if (newResults.length) {
for (const { receipt } of newResults)
for (const { receipt } of newResults) {
completed.current.add(receipt.evidence_id);
watched.current.delete(receipt.evidence_id);
}
if (rawOnly) setSavedToast("已保存原文");
onSavedRef.current();
}
for (const { receipt } of value)
if (receipt.state === "failed")
watched.current.delete(receipt.evidence_id);
} catch {
if (isOpen.current && request === additionsRequest.current)
if (
(isOpen.current || allowWhileClosed) &&
request === additionsRequest.current
)
setError(failure);
}
},
[reportPending],
[rawOnly, reportPending],
);
useEffect(() => {
@@ -193,6 +234,15 @@ export function MemoryAddComposer({
const before = document.activeElement;
panel.current?.querySelector("textarea")?.focus();
void refreshAdditions("最近添加的内容读取失败。");
if (rawOnly) {
setModel(null);
setModelRead(true);
return () => {
isOpen.current = false;
additionsRequest.current += 1;
if (before instanceof HTMLElement && before.isConnected) before.focus();
};
}
setModelRead(false);
getModelProfiles()
.then((value) => {
@@ -211,32 +261,38 @@ export function MemoryAddComposer({
additionsRequest.current += 1;
if (before instanceof HTMLElement && before.isConnected) before.focus();
};
}, [open, refreshAdditions, suspended]);
}, [open, rawOnly, refreshAdditions, suspended]);
useEffect(() => {
if (open || suspended || !onPendingChange) return;
void loadRecentAdditions()
.then((value) => reportPending(value))
.catch(() => {
// The page already keeps the last known state when a background read
// fails; do not turn an unknown pending count into zero here.
});
}, [open, onPendingChange, reportPending, suspended]);
void refreshAdditions(
"最近添加的内容读取失败。",
lastSaved.current !== null,
true,
);
}, [open, onPendingChange, refreshAdditions, suspended]);
const working = additions.some(
({ receipt }) => receipt.state === "pending" || receipt.state === "running",
);
useEffect(() => {
if (!open || suspended || !working) return;
if (suspended || !working) return;
const stop = subscribePoll({
// A key of its own: sharing one with another surface would make one
// tick read the wrong endpoint for the other caller.
key: "memory-add-processing",
intervalMs: 1_500,
run: () => refreshAdditions("整理状态暂时不可用,原话已保存。", true),
run: () =>
refreshAdditions(
rawOnly
? "保存状态暂时不可用,原文已保存。"
: "整理状态暂时不可用,原话已保存。",
true,
!open,
),
});
return stop;
}, [open, working, refreshAdditions, suspended]);
}, [open, rawOnly, working, refreshAdditions, suspended]);
useEffect(() => {
if (!savedToast) return;
@@ -273,12 +329,27 @@ export function MemoryAddComposer({
setNotice("");
const trace = traceUiAction("memory", "submit");
const completeSave = (receipt: MemoryAddReceipt) => {
if (
receipt.state === "ready" ||
(rawOnly && receipt.state === "no_memories")
)
completed.current.add(receipt.evidence_id);
else if (receipt.state === "pending" || receipt.state === "running")
watched.current.add(receipt.evidence_id);
remember({ requestId, receipt });
lastSaved.current = receipt.evidence_id;
setText("");
intent.current = null;
setNotice("");
setSavedToast("已保存,正在后台沉淀记忆");
setSavedToast(
rawOnly
? receipt.state === "ready" || receipt.state === "no_memories"
? "已保存原文"
: receipt.state === "failed"
? states.failed
: "正在保存…"
: "已保存,正在后台沉淀记忆",
);
isOpen.current = false;
additionsRequest.current += 1;
setOpen(false);
@@ -321,7 +392,8 @@ export function MemoryAddComposer({
data-tour-id="memory-add-trigger"
onClick={() => setOpen(true)}
>
<FilePlus2 size={16} aria-hidden="true" /> {copy("添加记忆")}
<FilePlus2 size={16} aria-hidden="true" />{" "}
{copy(rawOnly ? "手动添加" : "添加记忆")}
</button>
{savedToast && (
<p className="memory-add-toast" role="status">
@@ -360,11 +432,13 @@ export function MemoryAddComposer({
}}
>
<header className="memory-add-heading">
<h2 id="memory-add-title">{copy("添加记忆")}</h2>
<h2 id="memory-add-title">
{copy(rawOnly ? "手动添加" : "添加记忆")}
</h2>
<button
className="quiet"
type="button"
aria-label={copy("关闭新增记忆")}
aria-label={copy(rawOnly ? "关闭" : "关闭新增记忆")}
title={copy("关闭")}
disabled={busy}
onClick={close}
@@ -390,11 +464,13 @@ export function MemoryAddComposer({
placeholder={copy("例如:项目周会不排在周五。")}
onChange={(event) => setText(event.target.value)}
/>
<p className="memory-add-model">
{copy("整理模型:")}
{model?.name ??
copy(modelRead ? "尚未配置,原话仍可保存" : "正在读取")}
</p>
{!rawOnly && (
<p className="memory-add-model">
{copy("整理模型:")}
{model?.name ??
copy(modelRead ? "尚未配置,原话仍可保存" : "正在读取")}
</p>
)}
{error && (
<p className="error-banner" role="alert">
{copy(error)}
@@ -402,7 +478,7 @@ export function MemoryAddComposer({
)}
{notice && <p role="status">{copy(notice)}</p>}
<div className="consent-actions">
{modelRead && !model && (
{!rawOnly && modelRead && !model && (
<button
type="button"
className="secondary"
@@ -430,7 +506,19 @@ export function MemoryAddComposer({
{additions.map(({ requestId, receipt }) => (
<li key={requestId}>
<p>{receipt.text}</p>
<small>{copy(states[receipt.state])}</small>
<small>
{copy(
rawOnly &&
(receipt.state === "pending" ||
receipt.state === "running")
? "正在保存…"
: rawOnly &&
(receipt.state === "ready" ||
receipt.state === "no_memories")
? "已保存原文"
: states[receipt.state],
)}
</small>
{memoryWriteErrorText(receipt.error) && (
<p className="error-banner">
{copy(memoryWriteErrorText(receipt.error) ?? "")}
+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 && (
+99
View File
@@ -149,6 +149,102 @@ it("OBS-22: the graph inspector has no editing entry and never says changes cann
expect(screen.queryByText(/暂不能保存更改/)).toBeNull();
});
it("keeps topic annotations out of the inspector and opens detail-bearing graph nodes", async () => {
const onOpen = vi.fn();
const topicGraph = {
total_memories: 2,
projected_memories: 2,
hy_graph_available: true,
nodes: [
{
node_id: "memory:m0",
node_type: "memory",
memory_id: "m0",
label: "第一条",
},
{
node_id: "memory:outside",
node_type: "memory",
memory_id: "outside",
label: "图中但不在当前页的记忆",
},
{
node_id: "topic:git",
node_type: "topic",
memory_id: null,
label: "git",
},
{
node_id: "schema:workflow",
node_type: "schema",
memory_id: null,
label: "先验证,再推送",
},
],
edges: [
{
edge_id: "topic-m0",
source_node_id: "topic:git",
target_node_id: "memory:m0",
relation: "tagged_with",
origin: "shared_tag",
},
{
edge_id: "topic-outside",
source_node_id: "topic:git",
target_node_id: "memory:outside",
relation: "tagged_with",
origin: "shared_tag",
},
{
edge_id: "schema-m0",
source_node_id: "schema:workflow",
target_node_id: "memory:m0",
relation: "derived_from",
origin: "hy_relation",
},
],
} as MemoryGraphResponse;
render(
<Host
simplified
items={[memories[0]]}
graphValue={topicGraph}
onOpenSpy={onOpen}
libraryView={pagedView({
items: [memories[0]],
total: 2,
offset: 0,
limit: 1,
source_ids_by_memory: { m0: ["codex"] },
})}
/>,
);
await act(async () => {});
onOpen.mockClear();
await userEvent.click(screen.getByRole("button", { name: "图中选择 git" }));
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: "图中选择 先验证,再推送" }),
);
expect(screen.getByRole("heading", { name: "先验证,再推送" })).toBeTruthy();
expect(screen.queryByText("关系归纳")).toBeNull();
expect(screen.queryByText("归纳自")).toBeNull();
await userEvent.click(
screen.getByRole("button", {
name: "图中选择 图中但不在当前页的记忆",
}),
);
expect(onOpen).toHaveBeenLastCalledWith("outside");
expect(screen.getByTestId("canvas").dataset.selected).toBe("memory:outside");
expect(screen.queryByText("所选记忆不在当前筛选内,已取消选择。")).toBeNull();
});
it("OBS-19: Enter in the memory filter submits it; submitting it again re-reads", async () => {
const onRetry = vi.fn();
const onQuery = vi.fn();
@@ -391,6 +487,7 @@ function Host({
deferMemoryId,
failMemoryId,
graphValue = graph,
onOpenSpy,
onViewHistory,
onConfigureModel = () => {},
libraryView,
@@ -408,6 +505,7 @@ function Host({
deferMemoryId?: string;
failMemoryId?: string;
graphValue?: MemoryGraphResponse;
onOpenSpy?(memoryId: string): void;
onViewHistory?(): void;
onConfigureModel?(): void;
libraryView?: Parameters<typeof MemoryMap>[0]["libraryView"];
@@ -445,6 +543,7 @@ function Host({
streaming={null}
requestIds={{ current: new Map() }}
onOpenMemory={(id) => {
onOpenSpy?.(id);
if (failMemoryId && id === failMemoryId) {
setDetail(null);
setDetailLoad({ memoryId: id, status: "failed" });
+142 -51
View File
@@ -1,5 +1,6 @@
import type {
GovernedMemory,
HomeProjectionCapabilities,
HostAskMemoryResponse,
MemoryDetail,
MemoryGraphNode,
@@ -8,6 +9,8 @@ import type {
MemoryProcessingSummary,
SdkAskMemory,
SdkMemoryScene,
SdkRawContextItem,
SdkRawRecord,
SourceResponse,
} from "@one-memory/contracts";
import type { GuiLocale } from "@one-memory/i18n";
@@ -30,6 +33,7 @@ import {
useRef,
useState,
} from "react";
import { useGuiLanguage } from "./gui-language";
import { HandoffBar } from "./HandoffBar";
import { MemoryEvidencePanel } from "./MemoryEvidencePanel";
import { MemoryGraph3D } from "./MemoryGraph3D";
@@ -48,6 +52,7 @@ import {
import { OptionMenu } from "./OptionMenu";
import { R6Button } from "./R6Controls";
import { SelectedMemoryDetail } from "./SelectedMemoryDetail";
import { SelectedRawDetail } from "./SelectedRawDetail";
import { sourceNameOr } from "./source-label";
import { useMediaPreference } from "./use-media-preference";
import { Answer, NodeDetail, type SavedAnswer } from "./Workbench";
@@ -88,7 +93,10 @@ type Props = {
hidden?: boolean;
id?: string;
arrival?: MemoryArrival;
modeCapabilities?: HomeProjectionCapabilities | null;
onViewHistory?(): void;
contentKind?: "memory" | "raw";
rawRecord?: SdkRawRecord | SdkRawContextItem | null;
libraryView?: {
query: string;
source: string;
@@ -171,6 +179,8 @@ const SCENE_LABELS: Record<SdkMemoryScene, string> = {
export function MemoryMap(props: Props) {
const { locale, text, count, plain } = useMemoryLanguage();
const { t } = useGuiLanguage();
const raw = props.contentKind === "raw";
const {
memories,
graph: baseGraph,
@@ -260,6 +270,7 @@ export function MemoryMap(props: Props) {
processing: props.processing,
sources,
loaded,
capabilities: props.modeCapabilities,
});
useEffect(() => {
if (!loaded || library) return;
@@ -411,6 +422,15 @@ export function MemoryMap(props: Props) {
)),
) ?? null)
: null;
const projectedMemoryIds = useMemo(
() =>
new Set(
projection.nodes.flatMap((node) =>
node.memory_id ? [node.memory_id] : [],
),
),
[projection.nodes],
);
const selectedSource =
visibleGroupNode?.node_type === "source"
? sources.find(
@@ -428,9 +448,12 @@ export function MemoryMap(props: Props) {
}
}, [groupNode, visibleGroupNode]);
const requestedId = selectedId ?? detail?.memory.memory_id ?? null;
const activeId = active.some((memory) => memory.memory_id === requestedId)
? requestedId
: null;
const activeId =
requestedId &&
(active.some((memory) => memory.memory_id === requestedId) ||
projectedMemoryIds.has(requestedId))
? requestedId
: null;
const inspectorIdentity = activeId
? `memory:${activeId}`
: selectedSource
@@ -440,7 +463,8 @@ export function MemoryMap(props: Props) {
const visibleDetail =
detail &&
(!activeId || activeId === detail.memory.memory_id) &&
filtered.some((m) => m.memory_id === detail.memory.memory_id)
(filtered.some((m) => m.memory_id === detail.memory.memory_id) ||
projectedMemoryIds.has(detail.memory.memory_id))
? detail
: null;
const lastDetail = useRef(visibleDetail);
@@ -458,7 +482,8 @@ export function MemoryMap(props: Props) {
props.detailLoad.status === "failed";
const pick = useCallback(
(memoryId: string, scroll = true) => {
if (!active.some((memory) => memory.memory_id === memoryId)) return;
const listed = active.some((memory) => memory.memory_id === memoryId);
if (!listed && !projectedMemoryIds.has(memoryId)) return;
if (pendingPageOffset !== null && library?.failed && library.page)
library.onPage(library.page.offset);
setPendingSourceSelection(null);
@@ -467,7 +492,7 @@ export function MemoryMap(props: Props) {
pageRetrySawLoading.current = false;
setDetailResetRevision((current) => current + 1);
scrollSelection.current = scroll;
if (!filtered.some((memory) => memory.memory_id === memoryId)) {
if (listed && !filtered.some((memory) => memory.memory_id === memoryId)) {
setGroupKey("");
setSearch("");
setFilterNotice("已清除筛选,定位到关联记忆。");
@@ -477,7 +502,14 @@ export function MemoryMap(props: Props) {
setInspectorOpen(true);
onOpenMemory(memoryId);
},
[active, filtered, library, onOpenMemory, pendingPageOffset],
[
active,
filtered,
library,
onOpenMemory,
pendingPageOffset,
projectedMemoryIds,
],
);
useEffect(() => {
if (
@@ -597,12 +629,18 @@ export function MemoryMap(props: Props) {
return;
if (
requestedId &&
!active.some((memory) => memory.memory_id === requestedId)
!active.some((memory) => memory.memory_id === requestedId) &&
!projectedMemoryIds.has(requestedId)
) {
clear();
return;
}
if (!activeId || filtered.some((memory) => memory.memory_id === activeId))
const listed = active.some((memory) => memory.memory_id === activeId);
if (
!activeId ||
filtered.some((memory) => memory.memory_id === activeId) ||
(!listed && projectedMemoryIds.has(activeId))
)
return;
clear();
setFilterNotice("所选记忆不在当前筛选内,已取消选择。");
@@ -615,6 +653,7 @@ export function MemoryMap(props: Props) {
overlaysSuspended,
library?.loading,
pendingPageOffset,
projectedMemoryIds,
]);
function closeInspector() {
if (!compact) {
@@ -645,6 +684,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;
@@ -669,11 +711,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 ??
@@ -699,6 +736,7 @@ export function MemoryMap(props: Props) {
: "";
if (
props.simplified &&
!raw &&
(!library ||
(!library.loading &&
!library.failed &&
@@ -779,6 +817,17 @@ export function MemoryMap(props: Props) {
value={controlledSource}
options={[
{ value: "", label: text("全部应用") },
...(raw &&
!sources.some(
(source) => source.source_id === "one-memory-manual",
)
? [
{
value: "source:one-memory-manual",
label: text("手动添加"),
},
]
: []),
...(library?.source &&
library.source !== library.unattributedSource &&
!sources.some(
@@ -860,11 +909,15 @@ export function MemoryMap(props: Props) {
>
<Search size={16} aria-hidden="true" />
<input
aria-label={text("筛选地图记忆")}
aria-label={raw ? t("sdk.raw.searchLabel") : text("筛选地图记忆")}
name="map-memory-search"
autoComplete="off"
placeholder={
props.simplified ? text("搜索记忆") : text("筛选记忆…")
raw
? t("sdk.raw.searchLabel")
: props.simplified
? text("搜索记忆")
: text("筛选记忆…")
}
value={library ? searchDraft : search}
maxLength={library ? 512 : undefined}
@@ -971,7 +1024,7 @@ export function MemoryMap(props: Props) {
<ul
ref={memoryList}
className="map-memory-list"
aria-label={text("地图记忆列表")}
aria-label={raw ? t("memory:原始对话") : text("地图记忆列表")}
id="memory-pane-memories"
>
{filtered.map((m) => (
@@ -1014,12 +1067,12 @@ export function MemoryMap(props: Props) {
m.updated_at,
locale,
)}`}
{/* D99: the conversation date alone would hide that this
one was made today, so the generated day stays too. */}
<span className="map-memory-made">
{text("沉淀于 ")}
{dayStamp(m.updated_at, locale)}
</span>
{!raw ? (
<span className="map-memory-made">
{text("沉淀于 ")}
{dayStamp(m.updated_at, locale)}
</span>
) : null}
</>
) : (
text("已可用 · {count} 段原文", {
@@ -1033,9 +1086,11 @@ export function MemoryMap(props: Props) {
</ul>
{library?.failed && (
<div role="alert">
<p>{text("记忆读取失败,请重试。")}</p>
<p>
{raw ? t("sdk.raw.loadFailed") : text("记忆读取失败,请重试。")}
</p>
<R6Button onClick={retryLibrary}>
{text("重新读取地图记忆")}
{raw ? t("connectionFallback.retry") : text("重新读取地图记忆")}
</R6Button>
</div>
)}
@@ -1045,13 +1100,17 @@ export function MemoryMap(props: Props) {
!library.stale &&
!filtered.length && (
<p className="map-empty">
{library.query
? text("没有匹配的记忆")
: library.source
? text("这个来源下还没有记忆")
: groupKey
? text("本页没有匹配的主题记忆")
: text("还没有已生成的记忆")}
{raw
? library.query
? t("sdk.raw.found", { count: 0 })
: t("sdk.raw.empty")
: library.query
? text("没有匹配的记忆")
: library.source
? text("这个来源下还没有记忆")
: groupKey
? text("本页没有匹配的主题记忆")
: text("还没有已生成的记忆")}
</p>
)}
{library?.page?.search_window &&
@@ -1151,12 +1210,20 @@ export function MemoryMap(props: Props) {
<span className="map-kicker" hidden={props.simplified}>
{text(axis === "topic" ? "主题视角(非事项)" : "来源视角")}
</span>
<h2>{props.simplified ? text("记忆关联") : label}</h2>
<h2>
{props.simplified
? raw
? t("memory:原始对话")
: text("记忆关联")
: label}
</h2>
{props.simplified && (
<p className="selected-graph-subtitle">
{projection.nodes.length
? text("来源归属")
: text("可在沉淀记忆后查看视图")}
: raw
? t("sdk.raw.empty")
: text("可在沉淀记忆后查看视图")}
</p>
)}
</div>
@@ -1195,21 +1262,33 @@ export function MemoryMap(props: Props) {
: {})}
>
{library?.stale
? text("可在沉淀记忆后查看视图")
? raw
? t("sdk.raw.loadFailed")
: text("可在沉淀记忆后查看视图")
: library?.graphFailed || library?.failed
? text("记忆暂时无法读取")
? raw
? t("sdk.raw.loadFailed")
: text("记忆暂时无法读取")
: library?.graphLoading
? text("正在读取记忆地图…")
: library?.page &&
(library.query || library.source || groupKey)
? text("当前筛选没有可显示的记忆")
? raw
? t("sdk.raw.found", { count: 0 })
: text("当前筛选没有可显示的记忆")
: !loaded
? text("正在读取记忆…")
? raw
? t("sdk.raw.recent")
: text("正在读取记忆…")
: active.length
? text("当前筛选没有可显示的记忆")
: arrival.importedMessages > 0
? text("已保存的对话还未生成记忆")
: text("还没有可显示的记忆")}
? raw
? t("sdk.raw.found", { count: 0 })
: text("当前筛选没有可显示的记忆")
: raw
? t("sdk.raw.empty")
: arrival.importedMessages > 0
? text("已保存的对话还未生成记忆")
: text("还没有可显示的记忆")}
</p>
{library?.graphFailed ? (
<R6Button onClick={library.onRetry}>
@@ -1313,13 +1392,18 @@ export function MemoryMap(props: Props) {
>
<header className="map-inspector-heading">
<h2>
{inspectorDetail
? text("记忆详情")
: selectedSource
{raw
? t("memory:原始对话")
: inspectorDetail
? text("记忆详情")
: visibleGroupNode
? text("关联节点")
: text("记忆详情")}
: selectedSource
? text("记忆详情")
: visibleGroupNode
? visibleGroupNode.node_type === "topic" ||
visibleGroupNode.node_type === "schema"
? visibleGroupNode.label
: text("关联节点")
: text("记忆详情")}
</h2>
{(activeId || visibleGroupNode) && (
<button
@@ -1339,7 +1423,9 @@ export function MemoryMap(props: Props) {
{library?.failed &&
library.source === selectedSource.source_id ? (
<>
<p role="alert">{text("记忆暂时无法读取")}</p>
<p role="alert">
{raw ? t("sdk.raw.loadFailed") : text("记忆暂时无法读取")}
</p>
<button
type="button"
className="secondary"
@@ -1356,10 +1442,12 @@ export function MemoryMap(props: Props) {
<i />
<i />
</div>
<p role="status">{text("正在读取记忆详情…")}</p>
<p role="status">
{raw ? t("sdk.raw.recent") : text("正在读取记忆详情…")}
</p>
</>
) : (
<p>{text("还没有已生成的记忆")}</p>
<p>{raw ? t("sdk.raw.empty") : text("还没有已生成的记忆")}</p>
)}
</div>
) : props.simplified && inspectorDetail ? (
@@ -1423,6 +1511,8 @@ export function MemoryMap(props: Props) {
</div>
<p role="status">{text("正在读取这条记忆…")}</p>
</div>
) : raw && props.rawRecord ? (
<SelectedRawDetail record={props.rawRecord} sources={sources} />
) : (
<SelectedMemoryDetail
detail={inspectorDetail}
@@ -1452,6 +1542,7 @@ export function MemoryMap(props: Props) {
onDraft={props.onDraft}
onSave={props.onSave}
onDelete={props.onDelete}
onOpenMemory={pick}
requestIds={props.requestIds}
onClose={closeInspector}
/>
@@ -0,0 +1,64 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { afterEach, expect, it, vi } from "vitest";
import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
afterEach(cleanup);
const modes = [
{ mode: "lite" as const, title: "Lite", description: "Raw conversations" },
{ mode: "pro" as const, title: "Pro", description: "Extracted memories" },
];
it("supports native radio keyboard selection and reports the chosen control", async () => {
const changed = vi.fn();
function Harness() {
const [selected, setSelected] = useState<"lite" | "pro">("lite");
return (
<MemoryModeChoiceList
aria-label="Memory mode"
modes={modes}
selected={selected}
onChange={(mode, control) => {
changed(mode, control);
setSelected(mode as "lite" | "pro");
}}
/>
);
}
const user = userEvent.setup();
render(<Harness />);
const lite = screen.getByRole("radio", { name: /Lite/ });
lite.focus();
await user.keyboard("{ArrowDown}");
const pro = screen.getByRole<HTMLInputElement>("radio", { name: /Pro/ });
expect(pro.checked).toBe(true);
expect(changed).toHaveBeenCalledWith("pro", pro);
});
it("exposes current and recommended badges while respecting per-mode disabled state", () => {
render(
<MemoryModeChoiceList
aria-label="Memory mode"
modes={modes}
current="lite"
selected="lite"
currentBadge="Current"
recommended={{ mode: "pro", badge: "Recommended" }}
disabled={(mode) => mode === "pro"}
onChange={vi.fn()}
/>,
);
expect(screen.getByText("Current")).toBeTruthy();
expect(screen.getByText("Recommended")).toBeTruthy();
expect(screen.getByRole("radio", { name: /Pro/ })).toHaveProperty(
"disabled",
true,
);
});
@@ -0,0 +1,84 @@
import type { MemoryPipelineMode } from "@one-memory/contracts";
import { type ReactNode, useId } from "react";
import "./r6-controls.css";
export type MemoryModeChoice = {
mode: MemoryPipelineMode;
title: ReactNode;
description: ReactNode;
};
export function MemoryModeChoiceList({
modes,
current,
selected,
disabled = false,
onChange,
currentBadge,
recommended,
"aria-label": ariaLabel,
"aria-busy": ariaBusy,
}: {
modes: readonly MemoryModeChoice[];
current?: MemoryPipelineMode;
selected: MemoryPipelineMode | null;
disabled?: boolean | ((mode: MemoryPipelineMode) => boolean);
onChange(mode: MemoryPipelineMode, control: HTMLInputElement): void;
currentBadge?: ReactNode;
recommended?: {
mode: MemoryPipelineMode;
badge: ReactNode;
};
"aria-label": string;
"aria-busy"?: boolean;
}) {
const name = useId();
const isDisabled = (mode: MemoryPipelineMode) =>
typeof disabled === "function" ? disabled(mode) : disabled;
return (
<div
className="memory-mode-choice-list"
role="radiogroup"
aria-label={ariaLabel}
aria-busy={ariaBusy || undefined}
>
{modes.map((choice) => {
const checked = selected === choice.mode;
const unavailable = isDisabled(choice.mode);
return (
<label
key={choice.mode}
className="memory-mode-choice"
data-selected={checked || undefined}
data-current={current === choice.mode || undefined}
data-disabled={unavailable || undefined}
>
<input
type="radio"
name={name}
value={choice.mode}
checked={checked}
disabled={unavailable}
onChange={(event) => onChange(choice.mode, event.currentTarget)}
/>
<span className="memory-mode-choice-copy">
<span className="memory-mode-choice-heading">
<strong>{choice.title}</strong>
{recommended?.mode === choice.mode ? (
<span className="memory-mode-choice-badge">
{recommended.badge}
</span>
) : null}
</span>
<small>{choice.description}</small>
</span>
{current === choice.mode && currentBadge ? (
<span className="memory-mode-choice-current">{currentBadge}</span>
) : null}
</label>
);
})}
</div>
);
}
@@ -0,0 +1,458 @@
// @vitest-environment jsdom
import { legacyMemoryModePlan } from "@one-memory/operations";
import {
cleanup,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import {
apiErrorCode,
getEvolveOverview,
getEvolveSchedule,
getMemoryMode,
getMemoryProcessing,
memoryModePlanUnsupported,
planMemoryMode,
setEvolveSchedule,
setMemoryMode,
} from "./api";
import { MemoryModeSettings } from "./MemoryModeSettings";
vi.mock("./api", () => ({
getMemoryMode: vi.fn(),
setMemoryMode: vi.fn(),
getEvolveSchedule: vi.fn(),
setEvolveSchedule: vi.fn(),
getEvolveOverview: vi.fn(),
startEvolve: vi.fn(),
getMemoryProcessing: vi.fn(),
planMemoryMode: vi.fn(),
memoryModePlanUnsupported: vi.fn().mockReturnValue(false),
apiErrorCode: vi.fn().mockReturnValue(null),
}));
function state(mode: "lite" | "pro" | "ultra" = "pro") {
return {
mode,
configured_mode: mode,
explicitly_configured: true,
forced_by_env: false,
modes: ["lite", "pro", "ultra"] as Array<"lite" | "pro" | "ultra">,
graph_build: {
state: "idle" as const,
started_at: null,
finished_at: null,
users: {},
},
};
}
function prepare(mode: "lite" | "pro" | "ultra" = "pro") {
vi.mocked(getMemoryMode).mockResolvedValue(state(mode));
vi.mocked(getEvolveSchedule).mockResolvedValue({
enabled: true,
time: "03:00",
});
vi.mocked(getEvolveOverview).mockResolvedValue({
mode,
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
});
vi.mocked(getMemoryProcessing).mockResolvedValue({} as never);
vi.mocked(planMemoryMode).mockImplementation(async (target) =>
legacyMemoryModePlan(mode, target, { hasActiveModel: true }),
);
}
async function chooseMode(
user: ReturnType<typeof userEvent.setup>,
name: RegExp,
) {
const option = await screen.findByRole("radio", { name });
await user.click(option);
return option;
}
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.mocked(apiErrorCode).mockReturnValue(null);
vi.mocked(memoryModePlanUnsupported).mockReturnValue(false);
});
it("renders all settings modes as one grouped shared choice list", async () => {
prepare("pro");
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const group = await screen.findByRole("radiogroup", {
name: "当前模式",
});
expect(within(group).getAllByRole("radio")).toHaveLength(3);
expect(
within(group).getByText("当前模式").closest("label")?.textContent,
).toContain("智能记忆(pro)");
});
it("confirms the data effects before switching to lite", async () => {
prepare("pro");
vi.mocked(setMemoryMode).mockResolvedValue({
...state("lite"),
previous_mode: "pro",
effects: ["higher_mode_memories_hidden"],
});
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /基础记忆(lite)/);
expect(screen.getByText(/已有记忆继续保留/)).toBeTruthy();
expect(screen.getByText("模型调用与费用")).toBeTruthy();
expect(screen.getByText("搜索范围")).toBeTruthy();
expect(setMemoryMode).not.toHaveBeenCalled();
await user.click(
screen.getByRole("button", { name: "切换到基础记忆(lite)" }),
);
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("lite"));
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole("radio", { name: /基础记忆(lite)/ }),
),
);
});
it("traps modal focus, closes on Escape and restores the mode-card focus", async () => {
prepare("pro");
const outside = document.createElement("button");
document.body.append(outside);
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const lite = await chooseMode(user, /基础记忆(lite)/);
const cancel = screen.getByRole("button", { name: "取消" });
await waitFor(() => expect(document.activeElement).toBe(cancel));
expect(outside.inert).toBe(true);
await user.keyboard("{Shift>}{Tab}{/Shift}");
expect(document.activeElement).toBe(
screen.getByRole("button", { name: "切换到基础记忆(lite)" }),
);
await user.keyboard("{Escape}");
expect(screen.queryByRole("dialog")).toBeNull();
expect(outside.inert).toBeFalsy();
expect(document.activeElement).toBe(lite);
outside.remove();
});
it("keeps focus inside the modal while the mode change is pending", async () => {
prepare("pro");
vi.mocked(setMemoryMode).mockReturnValue(new Promise(() => {}));
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /基础记忆(lite)/);
await user.click(
screen.getByRole("button", { name: "切换到基础记忆(lite)" }),
);
const modal = screen.getByRole("dialog");
await waitFor(() => expect(document.activeElement).toBe(modal));
await user.keyboard("{Tab}");
expect(document.activeElement).toBe(modal);
});
it("routes to model setup instead of switching pro without a model", async () => {
prepare("lite");
vi.mocked(planMemoryMode).mockResolvedValue({
current: "lite",
target: "pro",
effective_target: "pro",
capabilities: {
content_plane: "memory",
generation_required: true,
graph_enabled: false,
evolve_available: false,
},
effects: ["earlier_raw_not_extracted", "lite_search_stops"],
warnings: [],
blockers: ["active_model_required"],
});
const configure = vi.fn();
const user = userEvent.setup();
render(
<MemoryModeSettings hasActiveModel={false} onConfigureModel={configure} />,
);
await chooseMode(user, /智能记忆(pro)/);
expect(configure).toHaveBeenCalledExactlyOnceWith(
"使用 pro 或 ultra 前需要先配置并启用生成模型。",
);
expect(setMemoryMode).not.toHaveBeenCalled();
});
it("uses the isolated legacy plan only for a route-level old-Core 404", async () => {
prepare("pro");
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, /基础记忆(lite)/);
expect(planMemoryMode).toHaveBeenCalledWith("lite");
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();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /深度记忆(ultra)/);
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 () => {
prepare("ultra");
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).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 () => {
prepare("pro");
vi.mocked(setMemoryMode).mockResolvedValue({
...state("ultra"),
previous_mode: "pro",
effects: ["existing_memories_added_to_graph"],
});
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await screen.findByRole("radio", { name: /智能记忆(pro)/ });
vi.mocked(getMemoryMode).mockResolvedValue(state("ultra"));
await chooseMode(user, /深度记忆(ultra)/);
await user.click(
screen.getByRole("button", { name: "切换到深度记忆(ultra)" }),
);
await waitFor(() =>
expect(document.activeElement?.getAttribute("id")).toContain("-evolve"),
);
expect(
screen.getByRole("heading", { name: "记忆整合(Evolve)" }),
).toBeTruthy();
});
it("shows the default nightly evolve controls in ultra", async () => {
prepare("ultra");
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
expect(
(await screen.findByRole("switch", { name: "每天自动整合" })).getAttribute(
"aria-checked",
),
).toBe("true");
expect((screen.getByLabelText("执行时间") as HTMLInputElement).value).toBe(
"03:00",
);
expect(screen.getByText(/只运行这一次,不改变每天的计划/)).toBeTruthy();
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
});
it("hides the time field when automatic evolve is off and keeps the saved time visible", async () => {
prepare("ultra");
vi.mocked(setEvolveSchedule).mockResolvedValue({
enabled: false,
time: "03:00",
});
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const automatic = await screen.findByRole("switch", {
name: "每天自动整合",
});
await user.click(automatic);
await screen.findByText(/重新开启后仍按 03:00 运行/);
expect(screen.queryByLabelText("执行时间")).toBeNull();
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
});
it("updates mode state automatically without showing a manual refresh control", async () => {
prepare("pro");
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const pro = await screen.findByRole("radio", { name: /智能记忆(pro)/ });
expect((pro as HTMLInputElement).checked).toBe(true);
expect(screen.queryByRole("button", { name: "刷新记忆模式" })).toBeNull();
expect(getMemoryMode).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(getEvolveSchedule).toHaveBeenCalledTimes(1);
});
});
it("keeps the mode usable when an evolve status read fails", async () => {
prepare("pro");
vi.mocked(getEvolveSchedule).mockRejectedValueOnce(new Error("offline"));
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
expect(
(await screen.findByRole("radio", {
name: /智能记忆(pro)/,
})) as HTMLInputElement,
).toHaveProperty("disabled", false);
});
it("offers a text recovery action when the mode read fails", async () => {
prepare("pro");
vi.mocked(getMemoryMode).mockRejectedValueOnce(new Error("offline"));
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
expect(
await screen.findByText("记忆模式状态暂时无法读取,请重试。"),
).toBeTruthy();
expect(
screen.getByRole("radio", { name: /基础记忆(lite)/ }),
).toHaveProperty("disabled", true);
await user.click(screen.getByRole("button", { name: "重新读取" }));
await waitFor(() =>
expect(
screen.getByRole("radio", { name: /智能记忆(pro)/ }),
).toHaveProperty("disabled", false),
);
});
it("keeps a failed switch explanation inside the open confirmation dialog", async () => {
prepare("pro");
vi.mocked(apiErrorCode).mockReturnValue("graph_store_unavailable");
vi.mocked(setMemoryMode).mockRejectedValue(new Error("unavailable"));
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /深度记忆(ultra)/);
await user.click(
screen.getByRole("button", { name: "切换到深度记忆(ultra)" }),
);
const dialog = screen.getByRole("dialog");
expect(
await within(dialog).findByText(
"当前安装缺少知识图谱组件,无法开启 ultra。",
),
).toBeTruthy();
});
it("keeps lite available while model status is unknown", async () => {
prepare("pro");
render(
<MemoryModeSettings
hasActiveModel={false}
modelStateKnown={false}
onConfigureModel={vi.fn()}
/>,
);
expect(
await screen.findByRole("radio", { name: /基础记忆(lite)/ }),
).toHaveProperty("disabled", false);
expect(screen.getByRole("radio", { name: /智能记忆(pro)/ })).toHaveProperty(
"disabled",
true,
);
expect(
screen.getByRole("radio", { name: /深度记忆(ultra)/ }),
).toHaveProperty("disabled", true);
});
it("blocks mode and Evolve controls during embedding maintenance", async () => {
prepare("ultra");
vi.mocked(getMemoryProcessing).mockResolvedValue({
embedding_switch: { phase: "running" },
} as never);
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
expect(
await screen.findByText(
"正在检查或切换向量模型,完成前不能切换模式或手动整合。",
),
).toBeTruthy();
expect(
screen.getByRole("radio", { name: /基础记忆(lite)/ }),
).toHaveProperty("disabled", true);
expect(screen.getByRole("button", { name: "立即整合" })).toHaveProperty(
"disabled",
true,
);
});
it("blocks lifecycle controls while the knowledge graph is being built", async () => {
prepare("ultra");
vi.mocked(getMemoryMode).mockResolvedValue({
...state("ultra"),
graph_build: {
state: "running",
started_at: "2026-11-03T09:00:00Z",
finished_at: null,
users: {},
},
});
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
expect(
await screen.findByText("正在把已有高级记忆加入知识图谱…"),
).toBeTruthy();
expect(screen.getByRole("button", { name: "立即整合" })).toHaveProperty(
"disabled",
true,
);
});
+560
View File
@@ -0,0 +1,560 @@
import type {
EvolveOverview,
EvolveRunReceipt,
EvolveSchedule,
MemoryModeChange,
MemoryModePlan,
MemoryModeState,
MemoryPipelineMode,
} from "@one-memory/contracts";
import { legacyMemoryModePlan } from "@one-memory/operations";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import {
apiErrorCode,
getEvolveOverview,
getEvolveSchedule,
getMemoryMode,
getMemoryProcessing,
memoryModePlanUnsupported,
planMemoryMode,
setEvolveSchedule,
setMemoryMode,
startEvolve,
} from "./api";
import {
commitConfirmedMemoryMode,
issueConfirmedModeTicket,
} from "./confirmed-memory-mode";
import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button, R6Switch } 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:
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;
}
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 MemoryModeSettings({
hasActiveModel,
modelStateKnown = true,
onConfigureModel,
onModeChange,
}: {
hasActiveModel: boolean;
modelStateKnown?: boolean;
onConfigureModel(message?: string): void;
onModeChange?(mode: MemoryPipelineMode): void;
}) {
const { text, locale } = useOnboardingTranslation();
const id = useId();
const [state, setState] = useState<MemoryModeState | null>(null);
const [schedule, setScheduleState] = useState<EvolveSchedule | null>(null);
const [overview, setOverview] = useState<EvolveOverview | null>(null);
const [pendingMode, setPendingMode] = useState<MemoryPipelineMode | null>(
null,
);
const [pendingPlan, setPendingPlan] = useState<MemoryModePlan | null>(null);
const [busy, setBusy] = useState(false);
const [maintenance, setMaintenance] = useState(false);
const [error, setError] = useState("");
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")
return;
focusEvolveWhenReady.current = false;
document.getElementById(`${id}-evolve`)?.focus();
}, [id, pendingMode, state?.mode]);
useEffect(() => {
const target = focusModeWhenReady.current;
if (!target || busy || pendingMode || state?.mode !== target) return;
focusModeWhenReady.current = null;
document
.querySelector<HTMLInputElement>(
`.r6-memory-mode-settings input[type="radio"][value="${target}"]`,
)
?.focus();
}, [busy, pendingMode, state?.mode]);
const refresh = useCallback(async () => {
const version = ++refreshVersion.current;
const modeTicket = issueConfirmedModeTicket("settings");
const [modeResult, scheduleResult, overviewResult, processingResult] =
await Promise.allSettled([
getMemoryMode(),
getEvolveSchedule(),
getEvolveOverview(),
getMemoryProcessing(),
]);
if (version !== refreshVersion.current) return;
if (modeResult.status === "fulfilled") {
commitConfirmedMemoryMode(modeResult.value.mode, modeTicket);
setState(modeResult.value);
onModeChange?.(modeResult.value.mode);
setError("");
setReadFailed(false);
} else {
setError("记忆模式状态暂时无法读取,请重试。");
setReadFailed(true);
}
if (scheduleResult.status === "fulfilled")
setScheduleState(scheduleResult.value);
if (overviewResult.status === "fulfilled") {
setOverview(overviewResult.value);
setRun(overviewResult.value.running[0] ?? null);
}
if (processingResult.status === "fulfilled")
setMaintenance(Boolean(processingResult.value.embedding_switch));
}, [onModeChange]);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
const running =
maintenance ||
state?.graph_build.state === "running" ||
run?.state === "running";
if (!running) return;
const timer = window.setInterval(() => void refresh(), 1500);
return () => window.clearInterval(timer);
}, [maintenance, refresh, run?.state, state?.graph_build.state]);
const lifecycleBusy =
maintenance ||
run?.state === "running" ||
state?.graph_build.state === "running";
async function choose(mode: MemoryPipelineMode, opener: HTMLElement) {
if (!state || mode === state.mode || busy || lifecycleBusy) return;
setError("");
setBusy(true);
let plan: MemoryModePlan;
try {
plan = await planMemoryMode(mode);
} catch (reason) {
if (!memoryModePlanUnsupported(reason)) {
setError("模式切换影响暂时无法读取,请重试。");
setBusy(false);
return;
}
plan = legacyMemoryModePlan(state.mode, mode, { hasActiveModel });
}
setBusy(false);
if (plan.blockers.includes("active_model_required")) {
onConfigureModel(BLOCKER_COPY.active_model_required);
return;
}
dialogOpener.current = opener;
setPendingPlan(plan);
setPendingMode(mode);
}
async function commitMode() {
if (!pendingMode) return;
const targetMode = pendingMode;
refreshVersion.current++;
const modeTicket = issueConfirmedModeTicket("settings");
setBusy(true);
setError("");
try {
const changed: MemoryModeChange = await setMemoryMode(pendingMode);
commitConfirmedMemoryMode(changed.mode, modeTicket);
focusEvolveWhenReady.current = targetMode === "ultra";
focusModeWhenReady.current = targetMode === "ultra" ? null : targetMode;
setState(changed);
onModeChange?.(changed.mode);
setPendingPlan(null);
setPendingMode(null);
await refresh();
} catch (reason) {
const code = apiErrorCode(reason);
setError(
code === "embedding_maintenance_in_progress"
? "正在检查或切换向量模型,完成后才能切换记忆模式。"
: code === "graph_store_unavailable"
? "当前安装缺少知识图谱组件,无法开启 ultra。"
: "模式切换未能确认,仍显示上次确认的模式。",
);
} finally {
setBusy(false);
}
}
async function changeSchedule(update: Partial<EvolveSchedule>) {
refreshVersion.current++;
setBusy(true);
setError("");
try {
setScheduleState(await setEvolveSchedule(update));
} catch {
setError("自动整合设置未能保存,请重试。");
} finally {
setBusy(false);
}
}
async function evolveNow() {
refreshVersion.current++;
setBusy(true);
setError("");
try {
const receipt = await startEvolve();
setRun(receipt);
await refresh();
} catch (reason) {
const code = apiErrorCode(reason);
setError(
code === "embedding_maintenance_in_progress"
? "正在检查或切换向量模型,完成后才能整合记忆。"
: "未能开始整合记忆,请重试。",
);
} finally {
setBusy(false);
}
}
const last = overview?.users.local;
return (
<section
className="r6-memory-mode-settings"
aria-labelledby="memory-mode-title"
>
<div className="r6-mode-heading">
<div>
<h2 id="memory-mode-title">{text("记忆模式")}</h2>
<p>{text("模式决定新对话如何保存,以及记忆页面展示什么。")}</p>
</div>
</div>
<MemoryModeChoiceList
aria-label={text("当前模式")}
aria-busy={state === null}
modes={(["lite", "pro", "ultra"] as const).map((mode) => ({
mode,
title: text(MODE_COPY[mode].title),
description: text(MODE_COPY[mode].description),
}))}
current={state?.mode}
selected={state?.mode ?? null}
currentBadge={text("当前模式")}
disabled={(mode) =>
!state ||
busy ||
lifecycleBusy ||
Boolean(state.forced_by_env && mode !== state.mode) ||
(mode !== "lite" && !modelStateKnown)
}
onChange={(mode, control) => void choose(mode, control)}
/>
{state?.forced_by_env ? (
<p className="r6-settings-note" role="status">
{text("开发环境当前强制使用 ultra,保存的选择暂不会生效。")}
</p>
) : null}
{maintenance ? (
<p className="r6-settings-note" role="status">
{text("正在检查或切换向量模型,完成前不能切换模式或手动整合。")}
</p>
) : null}
{state?.graph_build.state === "running" ? (
<p className="r6-settings-note" role="status">
{text("正在把已有高级记忆加入知识图谱…")}
</p>
) : null}
{error && !pendingMode ? (
readFailed ? (
<R6StateRecovery
message={text(error)}
onRetry={() => void refresh()}
/>
) : (
<p className="r6-settings-error" role="alert">
{text(error)}
</p>
)
) : null}
{state?.mode === "ultra" && schedule ? (
<section
id={`${id}-evolve`}
className="r6-evolve-settings"
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>
<div className="r6-evolve-setting r6-evolve-setting--manual">
<div className="r6-evolve-setting-copy">
<h3>{text("立即整合一次")}</h3>
<p>
{text("只运行这一次,不改变每天的计划。")}
{" · "}
{text(
run?.state === "running"
? "正在整合记忆…"
: last?.last_run_at
? text("上次整合:{time}").replace(
"{time}",
new Date(last.last_run_at).toLocaleString(locale),
)
: "尚未整合",
)}
</p>
</div>
<R6Button
pending={run?.state === "running"}
disabled={busy || lifecycleBusy}
onClick={() => void evolveNow()}
>
{text("立即整合")}
</R6Button>
</div>
</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>
) : null}
</section>
);
}
+477 -1
View File
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import type { SourceResponse } from "@one-memory/contracts";
import {
act,
cleanup,
@@ -7,6 +8,7 @@ import {
screen,
waitFor,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { MemorySpace } from "./MemorySpace";
@@ -74,6 +76,8 @@ const { reads } = vi.hoisted(() => ({
sdkGraph: vi.fn(),
processing: vi.fn(),
profiles: vi.fn(),
raw: vi.fn(),
rawSearch: vi.fn(),
},
}));
vi.mock("./api", async (importOriginal) => ({
@@ -82,17 +86,21 @@ vi.mock("./api", async (importOriginal) => ({
getSdkMemoryGraph: reads.sdkGraph,
getMemoryProcessing: reads.processing,
getModelProfiles: reads.profiles,
getSdkRaw: reads.raw,
searchSdkRaw: reads.rawSearch,
}));
vi.mock("./GraphWidthPrompt", () => ({ GraphWidthPrompt: () => null }));
vi.mock("./MemoryAddComposer", () => ({
MemoryAddComposer: ({
onPendingChange,
rawOnly,
}: {
onPendingChange?(addition: {
evidenceId: string;
text: string;
state: string;
}): void;
rawOnly?: boolean;
}) => (
<button
type="button"
@@ -104,7 +112,7 @@ vi.mock("./MemoryAddComposer", () => ({
})
}
>
补充记忆
{rawOnly ? "手动添加" : "补充记忆"}
</button>
),
}));
@@ -194,8 +202,476 @@ function setup() {
completed: 1,
});
reads.profiles.mockResolvedValue([]);
reads.raw.mockResolvedValue({
items: [],
total: 0,
limit: 100,
offset: 0,
});
reads.rawSearch.mockResolvedValue([]);
}
function modeProcessing(mode: "lite" | "pro" | "ultra") {
return {
running: 0,
pending: 0,
blocked_stages: [],
completed: 1,
pipeline: {
mode,
configured_mode: mode,
forced_by_env: false,
evolve: {
mode,
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
},
last_evolve_at: null,
graph_build: {
state: "idle",
started_at: null,
finished_at: null,
users: {},
},
features: {},
},
};
}
it("shows raw conversations instead of advanced memory in lite", async () => {
setup();
reads.processing.mockResolvedValue({
running: 0,
pending: 0,
blocked_stages: [],
completed: 1,
pipeline: {
mode: "lite",
configured_mode: "lite",
forced_by_env: false,
evolve: {
mode: "lite",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
},
last_evolve_at: null,
graph_build: {
state: "idle",
started_at: null,
finished_at: null,
users: {},
},
features: {},
},
});
reads.raw.mockResolvedValue({
items: [
{
raw_id: "raw-1",
content: "原始对话内容",
user_id: "local",
agent_id: "all-apps",
source: "cursor",
session_id: "session-1",
memory_at: null,
gmt_created: 1_761_000_000,
custom: {},
messages: [{ role: "user", content: "原始对话内容" }],
},
],
total: 1,
limit: 20,
offset: 0,
});
render(<MemorySpace simplified />);
expect(await screen.findAllByText("原始对话内容")).toHaveLength(2);
expect(
document.querySelector(".core-memory-map")?.hasAttribute("hidden"),
).toBe(false);
expect(screen.getByTestId("graph-owner")).toBeTruthy();
expect(screen.getByRole("textbox", { name: "搜索原始对话" })).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "手动添加" }));
expect(
screen.getByText(/正在保存… 项目周会统一安排在周三上午。/),
).toBeTruthy();
await userEvent.click(screen.getByRole("combobox", { name: "来源" }));
expect(screen.getByRole("option", { name: "手动添加" })).toBeTruthy();
expect(reads.sdkLibrary).not.toHaveBeenCalled();
expect(reads.sdkGraph).not.toHaveBeenCalled();
expect(reads.raw).toHaveBeenCalledWith(
expect.objectContaining({
source: undefined,
offset: 0,
limit: 20,
signal: expect.any(AbortSignal),
}),
);
expect(document.querySelector(".memory-mode-label")?.textContent).toBe(
"lite",
);
expect(document.querySelector(".memory-mode-notice")).toBeNull();
});
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 source = "source" in options ? options.source : undefined;
const rawId = `${source ?? "all"}-${offset}`;
return {
items: [
{
raw_id: rawId,
content: `原始会话 ${rawId}`,
user_id: "local",
agent_id: "all-apps",
source: source ?? "cursor",
session_id: `session-${rawId}`,
memory_at: null,
gmt_created: 1_761_000_000 - offset,
custom: {},
messages: [{ role: "user", content: `原始会话 ${rawId}` }],
},
],
total: source ? 1 : 21,
limit: 20,
offset,
};
});
const user = userEvent.setup();
render(
<MemorySpace
simplified
sources={
[
{
source_id: "cursor",
display_name: "Cursor",
authorized: true,
},
] as SourceResponse[]
}
/>,
);
expect(await screen.findAllByText("原始会话 all-0")).toHaveLength(2);
await user.click(screen.getByRole("button", { name: "下一页记忆" }));
expect(await screen.findAllByText("原始会话 all-20")).toHaveLength(2);
expect(reads.raw).toHaveBeenLastCalledWith(
expect.objectContaining({ source: undefined, offset: 20, 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(reads.raw).toHaveBeenLastCalledWith(
expect.objectContaining({ source: "cursor", offset: 0, limit: 20 }),
);
await user.click(screen.getByRole("combobox", { name: "来源" }));
await user.click(screen.getByRole("option", { name: "手动添加" }));
expect(
await screen.findAllByText("原始会话 one-memory-manual-0"),
).toHaveLength(2);
expect(reads.raw).toHaveBeenLastCalledWith(
expect.objectContaining({
source: "one-memory-manual",
offset: 0,
limit: 20,
}),
);
});
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();
reads.processing.mockResolvedValue(modeProcessing("pro"));
reads.raw.mockResolvedValue({
items: [
{
raw_id: "raw-after-switch",
content: "切换后的原始会话",
user_id: "local",
agent_id: "all-apps",
source: "cursor",
session_id: "session-after-switch",
memory_at: null,
gmt_created: 1_761_000_000,
custom: {},
messages: [{ role: "user", content: "切换后的原始会话" }],
},
],
total: 1,
limit: 20,
offset: 0,
});
render(<MemorySpace simplified />);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(reads.sdkLibrary).toHaveBeenCalled();
expect(screen.getAllByText("已保存的记忆").length).toBeGreaterThan(0);
reads.processing.mockResolvedValue(modeProcessing("lite"));
await act(async () => {
await vi.advanceTimersByTimeAsync(3_000);
});
await vi.waitFor(() => expect(reads.raw).toHaveBeenCalled(), {
timeout: 1_000,
});
expect(screen.getAllByText("切换后的原始会话")).toHaveLength(2);
expect(screen.queryByText("已保存的记忆")).toBeNull();
expect(document.querySelector(".memory-mode-label")?.textContent).toBe(
"lite",
);
const libraryCalls = reads.sdkLibrary.mock.calls.length;
reads.processing.mockResolvedValue(modeProcessing("ultra"));
await act(async () => {
await vi.advanceTimersByTimeAsync(3_000);
});
await vi.waitFor(
() =>
expect(reads.sdkLibrary.mock.calls.length).toBeGreaterThan(libraryCalls),
{ timeout: 1_000 },
);
expect(screen.getAllByText("已保存的记忆").length).toBeGreaterThan(0);
expect(screen.queryByText("切换后的原始会话")).toBeNull();
expect(document.querySelector(".memory-mode-label")?.textContent).toBe(
"ultra",
);
});
it("opens a search hit with the complete role transcript", async () => {
setup();
reads.processing.mockResolvedValue({
running: 0,
pending: 0,
blocked_stages: [],
completed: 1,
pipeline: {
mode: "lite",
configured_mode: "lite",
forced_by_env: false,
evolve: {
mode: "lite",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
},
last_evolve_at: null,
graph_build: {
state: "idle",
started_at: null,
finished_at: null,
users: {},
},
features: {},
},
});
reads.raw.mockImplementation(async (options = {}) =>
"sessionId" in options && options.sessionId
? {
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",
source: "cursor",
session_id: "session-search",
memory_at: null,
gmt_created: 1_761_000_000,
custom: {},
messages: [
{ role: "user", content: "完整问题" },
{ role: "assistant", content: "完整回答" },
],
},
],
total: 2,
limit: 500,
offset: 0,
}
: { items: [], total: 0, limit: 20, offset: 0 },
);
reads.rawSearch.mockResolvedValue([
{
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",
memory_at: null,
score: 0.9,
truncated: true,
},
]);
const user = userEvent.setup();
render(<MemorySpace simplified />);
const search = await screen.findByRole("textbox", {
name: "搜索原始对话",
});
await user.type(search, "完整");
await user.click(screen.getByRole("button", { name: "搜索" }));
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",
sessionId: "session-search",
limit: 500,
}),
);
});
it("does not request advanced memories before the pipeline mode is known", async () => {
setup();
let resolveProcessing!: (value: unknown) => void;
reads.processing.mockReturnValueOnce(
new Promise((resolve) => {
resolveProcessing = resolve;
}),
);
render(<MemorySpace simplified />);
await act(async () => Promise.resolve());
expect(reads.sdkLibrary).not.toHaveBeenCalled();
expect(reads.sdkGraph).not.toHaveBeenCalled();
await act(async () => {
resolveProcessing({
running: 0,
pending: 0,
blocked_stages: [],
completed: 0,
pipeline: {
mode: "lite",
configured_mode: "lite",
forced_by_env: false,
evolve: {
mode: "lite",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
},
last_evolve_at: null,
graph_build: {
state: "idle",
started_at: null,
finished_at: null,
users: {},
},
features: {},
},
});
});
await waitFor(() =>
expect(document.querySelector(".memory-mode-label")?.textContent).toBe(
"lite",
),
);
expect(reads.sdkLibrary).not.toHaveBeenCalled();
expect(reads.sdkGraph).not.toHaveBeenCalled();
});
it("keeps an added memory visible while it waits for model processing", async () => {
setup();
render(<MemorySpace simplified onConfigureModel={vi.fn()} />);
@@ -389,7 +389,7 @@ it("hides the previous source graph until the all-applications graph settles", a
expect(screen.getByTestId("map-graph-loading").textContent).toBe("true"),
);
expect(screen.getByTestId("map-nodes").textContent).toBe("none");
// Waiting for the graph is not page staleness: the list stays usable.
expect(screen.getByTestId("map-memories").textContent).toBe("");
expect(screen.getByTestId("map-stale").textContent).toBe("false");
await act(async () => {
@@ -419,6 +419,7 @@ it("hides the previous source graph until the all-applications graph settles", a
expect(screen.getByTestId("map-graph-loading").textContent).toBe("true"),
);
expect(screen.getByTestId("map-nodes").textContent).toBe("none");
expect(screen.getByTestId("map-memories").textContent).toBe("");
expect(api.sdkGraph).toHaveBeenCalledTimes(graphCalls);
await act(async () => releaseSearch(searchPage));
+286 -84
View File
@@ -1,6 +1,7 @@
import type {
ExtractionWindowPreset,
GovernedMemory,
HomeProjectionCapabilities,
HostAskMemoryResponse,
ImportJob,
MemoryDetail,
@@ -36,7 +37,9 @@ import {
getSdkMemory,
getSdkMemoryGraph,
getSdkMemoryLibrary,
getSdkRaw,
manualExtract,
searchSdkRaw,
setExtractionWindow,
updateSdkMemory,
} from "./api";
@@ -54,6 +57,13 @@ import {
sdkLibraryPage,
sdkNamespace,
} from "./sdk-memory-map";
import {
type RawMapRecord,
rawDetail,
rawLibraryPage,
rawRecordId,
rawSearchPage,
} from "./sdk-raw-map";
import { sourceNameOr, UNATTRIBUTED_SOURCE_ID } from "./source-label";
import { traceUiAction } from "./ui-trace";
import { isDefiniteRefusal, writeTraceStatus } from "./write-outcome";
@@ -109,6 +119,10 @@ export function MemorySpace({
return isMessageKey(key) ? t(key) : text;
};
const [detail, setDetail] = useState<MemoryDetail | null>(null);
const [selectedRaw, setSelectedRaw] = useState<RawMapRecord | null>(null);
const selectedRawRef = useRef<RawMapRecord | null>(null);
selectedRawRef.current = selectedRaw;
const rawRecords = useRef(new Map<string, RawMapRecord>());
const [detailLoad, setDetailLoad] = useState<{
memoryId: string;
status: "loading" | "failed";
@@ -183,17 +197,44 @@ export function MemorySpace({
/** The filter the shown page was read with, for the count above it. */
settledSource: string;
settledQuery: string;
settledMode: "lite" | "pro" | "ultra" | null;
}>({
page: null,
loading: false,
failed: false,
settledSource: "",
settledQuery: "",
settledMode: null,
});
const [sdkLibraryQuery, setSdkLibraryQuery] = useState("");
const [sdkLibrarySource, setSdkLibrarySource] = useState("");
const [sdkLibraryOffset, setSdkLibraryOffset] = useState(0);
const [processing, setProcessing] = useState<MemoryProcessingSummary | null>(
null,
);
const [processingFailed, setProcessingFailed] = useState(false);
const [processingResolved, setProcessingResolved] = useState(false);
const pipelineMode =
processing?.pipeline?.mode ??
(processingResolved && !processingFailed ? "pro" : null);
const lite = pipelineMode === "lite";
const modeCapabilities: HomeProjectionCapabilities | null = pipelineMode
? {
content_plane: lite ? "raw" : "memory",
generation_required: !lite,
graph_enabled: pipelineMode === "ultra",
evolve_available: pipelineMode === "ultra",
}
: null;
const requestedQuery = sdkLibraryQuery.trim();
const sdkPageSettled =
sdkLibrary.settledMode === pipelineMode &&
sdkLibrary.settledSource === sdkLibrarySource &&
sdkLibrary.settledQuery.trim() === requestedQuery &&
sdkLibrary.page?.offset === sdkLibraryOffset;
const sdkPage = sdkPageSettled ? sdkLibrary.page : null;
const sdkRequestedScope = JSON.stringify([
pipelineMode,
sdkLibraryQuery.trim(),
sdkLibrarySource,
]);
@@ -213,8 +254,9 @@ export function MemorySpace({
});
// The page the list shows, for the namespace of a memory being opened.
const sdkPageRef = useRef<MemoryLibraryPage | null>(null);
sdkPageRef.current = sdkLibrary.page;
const sdkMapVisible = simplified && active && displayedMode === "map";
sdkPageRef.current = sdkPage;
const sdkMapVisible =
simplified && active && displayedMode === "map" && pipelineMode !== null;
useEffect(() => {
if (!sdkMapVisible) return;
// A write bumps the revision; reading it here re-runs the read at once
@@ -224,7 +266,7 @@ export function MemorySpace({
let controller: AbortController | null = null;
const source = sdkLibrarySource || undefined;
const query = sdkLibraryQuery.trim() || undefined;
const windowKey = JSON.stringify([query ?? "", source ?? ""]);
const windowKey = JSON.stringify([pipelineMode, query ?? "", source ?? ""]);
if (sdkSearchWindow.current.key !== windowKey)
sdkSearchWindow.current = { key: windowKey, id: null };
const read = async (first: boolean) => {
@@ -233,42 +275,71 @@ export function MemorySpace({
controller = request;
if (first) setSdkLibrary((current) => ({ ...current, loading: true }));
try {
const [page, graph] = await Promise.all([
getSdkMemoryLibrary(
{
statuses: ["active"],
source,
query,
offset: sdkLibraryOffset,
limit: SDK_MEMORY_PAGE_SIZE,
windowId: query ? sdkSearchWindow.current.id : null,
},
request.signal,
),
// The overlay is decoration: the list stands without it. It reads
// the full active/source-filtered scene up to the API's visual cap.
// Reading only the first list-page prefix made "All applications"
// look identical to Cursor whenever newer Cursor memories occupied
// that prefix.
query
? Promise.resolve({
const rememberRaw = (items: RawMapRecord[]) => {
const next = new Map<string, RawMapRecord>();
if (selectedRawRef.current)
next.set(
rawRecordId(selectedRawRef.current),
selectedRawRef.current,
);
for (const item of items) next.set(rawRecordId(item), item);
rawRecords.current = next;
};
const [page, graph] = lite
? await Promise.all([
query
? searchSdkRaw(query, {
source,
signal: request.signal,
}).then((items) => {
rememberRaw(items);
return rawSearchPage(items);
})
: getSdkRaw({
source,
offset: sdkLibraryOffset,
limit: SDK_MEMORY_PAGE_SIZE,
signal: request.signal,
}).then((rawPage) => {
rememberRaw(rawPage.items);
return rawLibraryPage(rawPage);
}),
Promise.resolve({
status: "ready" as const,
value: null,
})
: getSdkMemoryGraph(
}),
])
: await Promise.all([
getSdkMemoryLibrary(
{
statuses: ["active"],
source,
offset: 0,
limit: 300,
query,
offset: sdkLibraryOffset,
limit: SDK_MEMORY_PAGE_SIZE,
windowId: query ? sdkSearchWindow.current.id : null,
},
request.signal,
).then(
(value) => ({ status: "ready" as const, value }),
() => ({ status: "failed" as const, value: null }),
),
]);
).then(sdkLibraryPage),
query
? Promise.resolve({
status: "ready" as const,
value: null,
})
: getSdkMemoryGraph(
{
source,
offset: 0,
limit: 300,
},
request.signal,
).then(
(value) => ({ status: "ready" as const, value }),
() => ({ status: "failed" as const, value: null }),
),
]);
if (cancelled) return;
if (sdkSearchWindow.current.key === windowKey)
if (!lite && sdkSearchWindow.current.key === windowKey)
sdkSearchWindow.current = {
key: windowKey,
id: page.window_id ?? null,
@@ -276,11 +347,12 @@ export function MemorySpace({
// The window expired: the server searched again and answered page 1.
if (page.window_reset && sdkLibraryOffset !== 0) setSdkLibraryOffset(0);
setSdkLibrary({
page: sdkLibraryPage(page),
page,
loading: false,
failed: false,
settledSource: sdkLibrarySource,
settledQuery: sdkLibraryQuery,
settledMode: pipelineMode,
});
setSdkGraph({ scope: windowKey, ...graph });
} catch {
@@ -305,6 +377,8 @@ export function MemorySpace({
sdkLibraryOffset,
sdkLibraryQuery,
sdkLibrarySource,
lite,
pipelineMode,
]);
const sdkFilterLabel = [
sdkLibrary.settledSource
@@ -337,9 +411,6 @@ export function MemorySpace({
}
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [processing, setProcessing] = useState<MemoryProcessingSummary | null>(
null,
);
const [pendingAdd, setPendingAdd] = useState<PendingMemoryAdd | null>(null);
const [checkingSetup, setCheckingSetup] = useState(false);
// biome-ignore lint/correctness/useExhaustiveDependencies: Source authority changes invalidate an in-flight setup snapshot.
@@ -394,6 +465,7 @@ export function MemorySpace({
),
refreshing: checkingSetup,
memoryCount: undefined,
capabilities: modeCapabilities,
blockedStages: processing?.blocked_stages,
backlogPending: processing?.backlog_pending,
phase: sourceSnapshotStale ? "unavailable" : "checking",
@@ -408,7 +480,11 @@ export function MemorySpace({
const update = async () => {
try {
const next = await getMemoryProcessing();
if (!stopped) setProcessing(next);
if (!stopped) {
setProcessing(next);
setProcessingFailed(false);
setProcessingResolved(true);
}
} catch {
// Background read failures do not invalidate the last verified state.
}
@@ -438,8 +514,29 @@ export function MemorySpace({
const closeDetail = useCallback(() => {
detailEpoch.current++;
setDetail(null);
setSelectedRaw(null);
setDetailLoad(null);
}, []);
const previousPipelineMode = useRef(pipelineMode);
useEffect(() => {
if (pipelineMode === null || previousPipelineMode.current === pipelineMode)
return;
previousPipelineMode.current = pipelineMode;
rawRecords.current.clear();
sdkSearchWindow.current = { key: "", id: null };
setSdkLibraryOffset(0);
setSdkLibraryQuery("");
setSdkGraph(null);
setSdkLibrary((current) => ({
...current,
page: null,
loading: true,
failed: false,
settledQuery: "",
settledMode: null,
}));
closeDetail();
}, [pipelineMode, closeDetail]);
const previousSourceKey = useRef(sourceKey);
useEffect(() => {
@@ -460,14 +557,23 @@ export function MemorySpace({
* this subscriber and the background arrival poll read the same endpoint,
* so one `fetch` per tick is the whole point of sharing the key. */
const consumeProcessing = (value: unknown) => {
if (!cancelled) setProcessing(value as MemoryProcessingSummary);
if (!cancelled) {
setProcessing(value as MemoryProcessingSummary);
setProcessingFailed(false);
setProcessingResolved(true);
}
};
// `subscribePoll` only schedules ticks; the first read still has to happen
// on mount. Do it once through the shared fetch so mount does not double-read
// with the background arrival subscriber.
void getMemoryProcessing()
.then(consumeProcessing)
.catch(() => {});
.catch(() => {
if (!cancelled) {
setProcessingFailed(true);
setProcessingResolved(true);
}
});
// The processing poll shares one read with the background arrival poll on
// the same key (3s wins over 12s).
const stopProcessing = subscribePoll({
@@ -593,6 +699,56 @@ export function MemorySpace({
const epoch = ++detailEpoch.current;
setError(null);
setDetail(null);
setSelectedRaw(null);
if (lite) {
let record = rawRecords.current.get(memoryId);
if (!record) {
setDetailLoad({
memoryId,
status: "failed",
error: t("sdk.raw.loadFailed"),
});
return;
}
if ("kind" in record) {
setDetailLoad({ memoryId, status: "loading" });
try {
let offset = 0;
while (true) {
const page = await getSdkRaw({
source: record.source || undefined,
sessionId: record.session_id || undefined,
offset,
limit: 500,
});
const full = page.items.find(
(item) => rawRecordId(item) === memoryId,
);
if (full) {
record = full;
rawRecords.current.set(memoryId, full);
break;
}
offset += page.items.length;
if (!page.items.length || offset >= page.total)
throw new Error("raw_not_found");
}
} catch {
if (epoch === detailEpoch.current)
setDetailLoad({
memoryId,
status: "failed",
error: t("sdk.raw.loadFailed"),
});
return;
}
if (epoch !== detailEpoch.current) return;
}
setSelectedRaw(record);
setDetail(rawDetail(record));
setDetailLoad(null);
return;
}
setDetailLoad({ memoryId, status: "loading" });
try {
// The inspector reads the memory through v2 and renders it in place.
@@ -728,37 +884,56 @@ export function MemorySpace({
</div>
<header className="memory-instrument-header">
<div>
<h1 id="memory-title">
{simplified ? t("memory:记忆") : t("memory:记忆空间")}
</h1>
<div className="memory-title-line">
<h1 id="memory-title">
{simplified ? t("memory:记忆") : t("memory:记忆空间")}
</h1>
{simplified && pipelineMode ? (
<span className="memory-mode-label">{pipelineMode}</span>
) : null}
</div>
<span className="memory-loaded-count">
{simplified && !management
? sdkLibrary.page
? tn(
"memory:已沉淀 {count} 条{filter} · 当前展示 {shown} 条",
sdkLibrary.page.total,
{
filter: sdkFilterLabel
? t("memory:({filter})", { filter: sdkFilterLabel })
: "",
shown: new Intl.NumberFormat(locale).format(
sdkLibrary.page.items.length,
),
},
)
: // The list below says a failed read once, with its retry.
sdkLibrary.failed
? null
: t("memory:正在读取…")
? pipelineMode === null
? t("memory:记忆暂时无法读取")
: lite
? sdkPage
? `${t("memory:原始对话")} · ${tn(
"sdk.raw.found",
sdkPage.total,
)}`
: sdkLibrary.failed
? null
: t("memory:正在读取…")
: sdkPage
? tn(
"memory:已沉淀 {count} 条{filter} · 当前展示 {shown} 条",
sdkPage.total,
{
filter: sdkFilterLabel
? t("memory:({filter})", {
filter: sdkFilterLabel,
})
: "",
shown: new Intl.NumberFormat(locale).format(
sdkPage.items.length,
),
},
)
: // The list below says a failed read once, with its retry.
sdkLibrary.failed
? null
: t("memory:正在读取…")
: null}
</span>
</div>
<MemoryAddComposer
suspended={!active}
rawOnly={lite}
onPendingChange={setPendingAdd}
onSaved={() => {
// A finished addition is a new memory: re-read the page now rather
// than leave it for the next poll.
// A finished addition is new page content: re-read now rather than
// leave it for the next poll.
bumpSdkMap();
}}
onOpenMemory={(id) => {
@@ -920,22 +1095,33 @@ export function MemorySpace({
</details>
) : null}
<ModelBackoffBanner
processing={processing}
onConfigureModel={onConfigureModel}
/>
<ModelPrompt
processing={processing}
hasMemories={(sdkLibrary.page?.total ?? 0) > 0}
onConfigureModel={onConfigureModel}
/>
{!lite ? (
<>
<ModelBackoffBanner
processing={processing}
onConfigureModel={onConfigureModel}
/>
<ModelPrompt
processing={processing}
hasMemories={(sdkPage?.total ?? 0) > 0}
onConfigureModel={onConfigureModel}
/>
</>
) : null}
{simplified && pendingAdd ? (
<p className="memory-pending-add" role="status">
<span>
{t("memory:正在等待整理:")}
{lite
? t(
pendingAdd.state === "failed"
? "memory:原话已保存,整理未完成"
: "memory:正在保存…",
)
: t("memory:正在等待整理:")}
{lite ? " " : null}
{pendingAdd.text}
</span>
{pendingAdd.state === "waiting_for_model" ? (
{!lite && pendingAdd.state === "waiting_for_model" ? (
<button type="button" onClick={onConfigureModel}>
{t("memory:配置整理模型")}
</button>
@@ -954,7 +1140,7 @@ export function MemorySpace({
id="memory-mode-panel-history"
hidden={displayedMode !== "history"}
>
<SdkRawHistory />
<SdkRawHistory active={active && displayedMode === "history"} />
</div>
)}
@@ -995,14 +1181,18 @@ export function MemorySpace({
{/* Native hiding retains the WebGL owner; its visibility observer pauses drawing. */}
<MemoryMap
key={simplified ? (pipelineMode ?? "unknown") : "full"}
id="memory-mode-panel-map"
simplified={simplified}
contentKind={lite ? "raw" : "memory"}
modeCapabilities={modeCapabilities}
rawRecord={selectedRaw}
libraryView={
simplified
? {
query: sdkLibraryQuery,
source: sdkLibrarySource,
page: sdkLibrary.page,
page: sdkPage,
loading: sdkLibrary.loading,
failed: sdkLibrary.failed,
graphLoading: !sdkGraphSettled,
@@ -1020,14 +1210,18 @@ export function MemorySpace({
}
: undefined
}
hidden={!active || displayedMode !== "map"}
memories={sdkLibrary.page?.items ?? []}
hidden={
!active ||
displayedMode !== "map" ||
(simplified && pipelineMode === null)
}
memories={sdkPage?.items ?? []}
graph={visibleSdkGraph}
sources={sources}
processing={processing}
arrival={memoryArrival}
onViewHistory={simplified ? onConnect : () => setMode("history")}
loaded={sdkLibrary.page !== null}
loaded={sdkPage !== null}
detail={detail}
detailLoad={detailLoad}
overlayBlocked={
@@ -1035,7 +1229,7 @@ export function MemorySpace({
deleteCount > 0 ||
// Only the paged map waits for its page; the three-column surface
// never reads one.
(simplified && sdkLibrary.page === null)
(simplified && sdkPage === null)
}
draft={draft}
busy={busy}
@@ -1128,10 +1322,12 @@ export function MemorySpace({
className="memory-library-layout"
hidden={displayedMode !== "library"}
>
<SdkMemoryLibrary
active={active}
focusedMemoryId={sdkFocusedMemoryId}
/>
{!simplified || (pipelineMode !== null && !lite) ? (
<SdkMemoryLibrary
active={active}
focusedMemoryId={sdkFocusedMemoryId}
/>
) : null}
</div>
</section>
);
@@ -1350,7 +1546,7 @@ function message(reason: unknown, t: Translate): string {
reason.message === "memory_answer_context_changed"
? reason.message
: apiErrorCode(reason);
const labels: Record<string, Parameters<Translate>[0]> = {
const labels: Record<string, string> = {
memory_answer_context_changed:
"memory:记忆内容或使用权限已变化,回答已停止。请重新提问。",
memory_source_revoked: "memory:这条记忆的来源授权已撤回,暂时不能读取。",
@@ -1363,8 +1559,14 @@ function message(reason: unknown, t: Translate): string {
memory_request_conflict: "memory:这次操作与已完成的请求不一致。",
request_validation_failed: "memory:输入内容不符合要求。",
local_store_unavailable: "memory:本地记忆库正忙,请稍后重试。",
embedding_unavailable:
"memory:搜索服务暂时不可用,无法检索记忆。请到设置中检查记忆搜索配置。",
embedding_switch_in_progress:
"memory:正在更新记忆索引,完成后才能修改或删除记忆。搜索仍可使用。",
embedding_space_profile_missing:
"memory:当前搜索模型配置已不存在,请到设置中重新配置记忆搜索。",
};
if (code && labels[code]) return t(labels[code]);
if (code && labels[code]) return t(labels[code] as Parameters<Translate>[0]);
// No code at all means the request never reached the core — usually because
// it is still starting. Saying only "failed" left the one case that resolves
// itself looking like the ones that do not.
+523 -17
View File
@@ -1,6 +1,12 @@
// @vitest-environment jsdom
import type { ModelProbeResult, ModelProfile } from "@one-memory/contracts";
import type {
EmbeddingSwitchCurrent,
EmbeddingSwitchJob,
EmbeddingSwitchPlan,
ModelProbeResult,
ModelProfile,
} from "@one-memory/contracts";
import {
act,
cleanup,
@@ -66,6 +72,11 @@ const probeProfile = vi.fn();
const activate = vi.fn();
const remove = vi.fn();
const update = vi.fn();
const refreshProfiles = vi.fn();
const planActivation = vi.fn();
const startPlannedSwitch = vi.fn();
let switchPlan: EmbeddingSwitchPlan | null = null;
let switchCurrent: EmbeddingSwitchCurrent | null = null;
it("explains an insufficient model balance with the required recovery", () => {
expect(probeLabel("insufficient_balance")).toBe(
@@ -114,6 +125,71 @@ const preset: ModelProfile = {
has_api_key: false,
disable_thinking: true,
};
const migrationPlan: EmbeddingSwitchPlan = {
needed: true,
reason: "embedding_identity_changed",
plan_token: "plan-token",
source: {
space_id: "old-space",
model: "old-search",
dims: 512,
collection_base: "memory",
vectors: 12,
},
target: {
space_id: null,
model: "new-search",
dims: 1024,
collection_base: "",
vectors: null,
},
target_profile_id: profile.profile_id,
target_revision: profile.revision,
target_provider_kind: "openai_compatible",
target_local: false,
sources: [],
total_vectors: 12,
users: 1,
graph_vectors: null,
estimate_s: 10,
requests: 1,
disk_bytes_needed: 1024,
disk_bytes_free: 4096,
retention_days: 14,
blockers: [],
};
const oldFinishedJob: EmbeddingSwitchJob = {
job_id: "old-job",
kind: "switch",
state: "done",
source: migrationPlan.source,
target: { ...migrationPlan.target, space_id: "new-space", vectors: 12 },
stray_space_ids: [],
target_profile_id: profile.profile_id,
target_revision: profile.revision,
requested_by: "app",
progress: {
phase: "done",
done: 12,
total: 12,
percent: 100,
embedded: 12,
reused: 0,
kept: 0,
role: null,
source_index: 0,
sources: 1,
rate_per_s: null,
eta_s: 0,
updated_at: "2026-11-03T09:00:00Z",
},
cancel_requested: false,
error_code: null,
attempt: 1,
created_at: "2026-11-03T09:00:00Z",
updated_at: "2026-11-03T09:00:01Z",
finished_at: "2026-11-03T09:00:01Z",
};
/** Re-renders on demand so a cleared journal is picked up, like a real refresh. */
function ProbeRecoveryHarness({
@@ -149,6 +225,20 @@ vi.mock("./use-model-profiles", () => ({
activate,
remove,
update,
refreshProfiles,
}),
}));
vi.mock("./use-embedding-switch", () => ({
useEmbeddingSwitch: () => ({
current: switchCurrent,
plan: switchPlan,
busy: null,
errorCode: null,
planActivation,
startPlannedSwitch,
shouldUseLegacyActivation: () => false,
clearPlan: vi.fn(),
clearError: vi.fn(),
}),
}));
@@ -187,8 +277,15 @@ describe("ModelSettings", () => {
probes = {};
pendingProbe = null;
profiles = [profile];
switchPlan = null;
switchCurrent = null;
create.mockResolvedValue(profile);
update.mockResolvedValue({ ...preset, has_api_key: true, revision: 2 });
refreshProfiles.mockResolvedValue(undefined);
planActivation.mockResolvedValue({
needed: false,
reason: "same_embedding",
});
});
it("submits a generation-only profile and leaves embedding to the builtin", async () => {
@@ -399,6 +496,62 @@ describe("ModelSettings", () => {
});
});
it("focuses generation without exposing or patching the search lane", async () => {
update.mockResolvedValue(profile);
render(<ModelSettings focusSection="generation" />);
await userEvent.click(screen.getByRole("button", { name: "编辑配置" }));
expect(screen.getByRole("heading", { name: "记忆整理" })).toBeTruthy();
expect(screen.queryByLabelText("搜索模型")).toBeNull();
expect(screen.getByLabelText("生成模型名")).toHaveProperty(
"value",
profile.model,
);
await userEvent.click(screen.getByRole("button", { name: "保存修改" }));
expect(update).toHaveBeenCalledExactlyOnceWith(profile.profile_id, {
expected_revision: profile.revision,
});
});
it("does not repair a hidden legacy search endpoint while editing generation", async () => {
const legacy = {
...profile,
embedding_provider_kind: "openai_compatible" as const,
embedding_model: "remote-search",
embedding_base_url: null,
};
profiles = [legacy];
update.mockResolvedValue(legacy);
render(<ModelSettings focusSection="generation" />);
await userEvent.click(screen.getByRole("button", { name: "编辑配置" }));
await userEvent.type(screen.getByLabelText("生成模型名"), "-new");
await userEvent.click(screen.getByRole("button", { name: "保存修改" }));
expect(update).toHaveBeenCalledExactlyOnceWith(profile.profile_id, {
expected_revision: profile.revision,
model: "memory-model-new",
});
});
it("focuses search on the recommended builtin without exposing or patching generation", async () => {
update.mockResolvedValue(profile);
render(<ModelSettings focusSection="embedding" />);
await userEvent.click(screen.getByRole("button", { name: "编辑配置" }));
expect(screen.getByRole("heading", { name: "记忆搜索" })).toBeTruthy();
expect(screen.queryByLabelText("生成模型名")).toBeNull();
expect(screen.getByLabelText("搜索模型")).toHaveProperty("value", "local");
expect(screen.getByText(/无需密钥/)).toBeTruthy();
expect(screen.getByText("技术类型:Embedding")).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "保存修改" }));
expect(update).toHaveBeenCalledExactlyOnceWith(profile.profile_id, {
expected_revision: profile.revision,
});
});
it("does not replace an edit draft with a newer external profile revision", async () => {
const view = render(<ModelSettings />);
await userEvent.click(screen.getByRole("button", { name: "编辑配置" }));
@@ -426,7 +579,8 @@ describe("ModelSettings", () => {
name: "高级:自行配置向量模型",
});
await user.click(disclosure);
await user.selectOptions(screen.getByLabelText("向量配置模式"), "custom");
await user.click(screen.getByLabelText("向量配置模式"));
await user.click(screen.getByRole("option", { name: "自定义搜索模型" }));
await user.type(screen.getByLabelText("向量模型名"), "custom-vector");
await user.type(
screen.getByLabelText("向量模型 Base URL"),
@@ -516,9 +670,9 @@ describe("ModelSettings", () => {
];
render(<ModelSettings />);
await userEvent.click(screen.getByRole("button", { name: "编辑配置" }));
await userEvent.selectOptions(
screen.getByLabelText("向量配置模式"),
"local",
await userEvent.click(screen.getByLabelText("向量配置模式"));
await userEvent.click(
screen.getByRole("option", { name: "本地内置(推荐)" }),
);
await userEvent.click(screen.getByRole("button", { name: "保存修改" }));
expect(update).toHaveBeenCalledWith(profile.profile_id, {
@@ -537,9 +691,9 @@ describe("ModelSettings", () => {
name: "高级:自行配置向量模型",
});
await userEvent.click(disclosure);
await userEvent.selectOptions(
screen.getByLabelText("向量配置模式"),
"custom",
await userEvent.click(screen.getByLabelText("向量配置模式"));
await userEvent.click(
screen.getByRole("option", { name: "自定义搜索模型" }),
);
await userEvent.click(disclosure);
await userEvent.click(screen.getByRole("button", { name: "保存配置" }));
@@ -661,9 +815,229 @@ describe("ModelSettings", () => {
expect(container.textContent).not.toContain("secret-value");
await user.click(screen.getByRole("button", { name: "设为当前模型" }));
expect(planActivation).toHaveBeenCalledWith("profile-1", 1);
expect(activate).toHaveBeenCalledWith("profile-1");
});
it("asks before activation when the search index must be rebuilt", async () => {
profiles = [
{
...profile,
capabilities: {
...profile.capabilities,
endpoint_reachable: true,
model_available: true,
embedding_available: true,
},
},
];
planActivation.mockImplementationOnce(async () => {
switchPlan = migrationPlan;
return migrationPlan;
});
switchCurrent = {
schema_version: 1,
phase: "idle",
writes_paused: false,
scan_state: "done",
scan_error: null,
job: null,
last_finished: oldFinishedJob,
};
render(<ModelSettings />);
await userEvent.click(screen.getByRole("button", { name: "设为当前模型" }));
expect(
await screen.findByRole("heading", { name: "更新记忆搜索索引?" }),
).toBeTruthy();
expect(activate).not.toHaveBeenCalled();
});
it("completes an embedding change only after the switch commit activates the target", async () => {
const onSetupComplete = vi.fn();
profiles = [
{
...profile,
capabilities: {
...profile.capabilities,
endpoint_reachable: true,
model_available: true,
embedding_available: true,
},
},
];
planActivation.mockImplementationOnce(async () => {
switchPlan = migrationPlan;
return migrationPlan;
});
const runningJob: EmbeddingSwitchJob = {
...oldFinishedJob,
job_id: "switch-job",
state: "reembedding",
progress: { ...oldFinishedJob.progress, phase: "vectors", done: 0 },
};
startPlannedSwitch.mockResolvedValue(runningJob);
switchCurrent = {
schema_version: 1,
phase: "idle",
writes_paused: false,
scan_state: "done",
scan_error: null,
job: null,
last_finished: oldFinishedJob,
};
const view = render(<ModelSettings onSetupComplete={onSetupComplete} />);
await userEvent.click(screen.getByRole("button", { name: "设为当前模型" }));
await userEvent.click(
await screen.findByRole("button", { name: "开始更新索引" }),
);
await waitFor(() =>
expect(startPlannedSwitch).toHaveBeenCalledExactlyOnceWith(),
);
expect(onSetupComplete).not.toHaveBeenCalled();
refreshProfiles.mockImplementationOnce(async () => {
profiles = [{ ...profiles[0], is_active: true }];
});
switchCurrent = {
...switchCurrent,
phase: "idle",
job: null,
last_finished: { ...runningJob, state: "done" },
};
view.rerender(<ModelSettings onSetupComplete={onSetupComplete} />);
await waitFor(() => expect(onSetupComplete).toHaveBeenCalledOnce());
});
it("does not complete a committed switch until the target revision is active", async () => {
const onSetupComplete = vi.fn();
profiles = [
{
...profile,
capabilities: {
...profile.capabilities,
endpoint_reachable: true,
model_available: true,
embedding_available: true,
},
},
];
planActivation.mockImplementationOnce(async () => {
switchPlan = migrationPlan;
return migrationPlan;
});
const runningJob: EmbeddingSwitchJob = {
...oldFinishedJob,
job_id: "switch-job",
state: "reembedding",
progress: { ...oldFinishedJob.progress, phase: "vectors", done: 0 },
};
startPlannedSwitch.mockResolvedValue(runningJob);
switchCurrent = {
schema_version: 1,
phase: "idle",
writes_paused: false,
scan_state: "done",
scan_error: null,
job: null,
last_finished: oldFinishedJob,
};
const view = render(<ModelSettings onSetupComplete={onSetupComplete} />);
await userEvent.click(screen.getByRole("button", { name: "设为当前模型" }));
await userEvent.click(
await screen.findByRole("button", { name: "开始更新索引" }),
);
await waitFor(() => expect(startPlannedSwitch).toHaveBeenCalledOnce());
switchCurrent = {
...switchCurrent,
phase: "idle",
job: null,
last_finished: { ...runningJob, state: "done" },
};
view.rerender(<ModelSettings onSetupComplete={onSetupComplete} />);
expect(
await screen.findByText(
"配置已保存,尚未确认启用成功。请核对配置后重试。",
),
).toBeTruthy();
expect(onSetupComplete).not.toHaveBeenCalled();
expect(
(
screen.getByRole("button", {
name: "保存并测试启用",
}) as HTMLButtonElement
).disabled,
).toBe(false);
});
it("keeps embedding configuration open when the switch rolls back", async () => {
const onSetupComplete = vi.fn();
profiles = [
{
...profile,
capabilities: {
...profile.capabilities,
endpoint_reachable: true,
model_available: true,
embedding_available: true,
},
},
];
planActivation.mockImplementationOnce(async () => {
switchPlan = migrationPlan;
return migrationPlan;
});
const runningJob: EmbeddingSwitchJob = {
...oldFinishedJob,
job_id: "switch-job",
state: "reembedding",
progress: { ...oldFinishedJob.progress, phase: "vectors", done: 0 },
};
startPlannedSwitch.mockResolvedValue(runningJob);
switchCurrent = {
schema_version: 1,
phase: "idle",
writes_paused: false,
scan_state: "done",
scan_error: null,
job: null,
last_finished: oldFinishedJob,
};
const view = render(<ModelSettings onSetupComplete={onSetupComplete} />);
await userEvent.click(screen.getByRole("button", { name: "设为当前模型" }));
await userEvent.click(
await screen.findByRole("button", { name: "开始更新索引" }),
);
await waitFor(() => expect(startPlannedSwitch).toHaveBeenCalledOnce());
switchCurrent = {
...switchCurrent,
phase: "idle",
job: null,
last_finished: {
...runningJob,
state: "rolled_back",
error_code: "embedding_switch_failed",
},
};
view.rerender(<ModelSettings onSetupComplete={onSetupComplete} />);
expect(
await screen.findByText(
"索引更新没有完成,仍在使用原来的搜索模型,记忆没有丢失。",
),
).toBeTruthy();
expect(onSetupComplete).not.toHaveBeenCalled();
});
it("hands a successful save to its card, clears only the key, and never tests or activates", async () => {
profiles = [];
const user = userEvent.setup();
@@ -714,6 +1088,51 @@ describe("ModelSettings", () => {
);
});
it("keeps required generation fields mounted when technical details collapse", async () => {
render(<ModelSettings />);
await userEvent.click(
screen.getByRole("button", { name: "配置详情与高级设置" }),
);
const modelField =
document.querySelector<HTMLInputElement>('[name="model"]');
const baseUrlField =
document.querySelector<HTMLInputElement>('[name="base_url"]');
expect(modelField).toBeTruthy();
expect(baseUrlField).toBeTruthy();
expect(modelField?.closest("[hidden]")).toBeTruthy();
expect(baseUrlField?.closest("[hidden]")).toBeTruthy();
});
it("reopens collapsed generation details and focuses the first invalid field", async () => {
render(<ModelSettings />);
await userEvent.click(
screen.getByRole("button", { name: "配置详情与高级设置" }),
);
await userEvent.click(screen.getByRole("button", { name: "保存配置" }));
const modelField = await screen.findByLabelText("生成模型名");
expect(
screen
.getByRole("button", { name: "配置详情与高级设置" })
.getAttribute("aria-expanded"),
).toBe("true");
await waitFor(() => expect(document.activeElement).toBe(modelField));
expect(create).not.toHaveBeenCalled();
});
it("clears inherited runtime values when an edit changes protocol and creates with Core defaults", async () => {
render(<ModelSettings />);
await userEvent.click(screen.getByRole("button", { name: "编辑配置" }));
expect(screen.getByLabelText("超时(秒)")).toHaveProperty("value", "60");
await userEvent.selectOptions(screen.getByLabelText("协议"), "ollama");
expect(screen.getByLabelText("超时(秒)")).toHaveProperty("value", "");
await userEvent.click(screen.getByRole("button", { name: "保存修改" }));
expect(create).toHaveBeenCalledOnce();
expect(create.mock.calls[0][0]).not.toHaveProperty("timeout_seconds");
expect(create.mock.calls[0][0]).not.toHaveProperty("max_concurrency");
expect(create.mock.calls[0][0]).not.toHaveProperty("context_window");
expect(update).not.toHaveBeenCalled();
});
it("shows field-local Chinese required errors for mouse and keyboard submission", async () => {
const user = userEvent.setup();
render(<ModelSettings />);
@@ -813,14 +1232,16 @@ describe("ModelSettings", () => {
await user.click(
screen.getByRole("button", { name: "高级:自行配置向量模型" }),
);
await user.selectOptions(screen.getByLabelText("向量配置模式"), "custom");
await user.click(screen.getByLabelText("向量配置模式"));
await user.click(screen.getByRole("option", { name: "自定义搜索模型" }));
await user.type(
screen.getByLabelText("配置名称"),
"Remote with local embedding",
);
await user.type(screen.getByLabelText("生成模型名"), "remote-model");
await user.type(screen.getByLabelText("向量模型名"), "nomic-embed-text");
await user.selectOptions(screen.getByLabelText("向量模型来源"), "ollama");
await user.click(screen.getByLabelText("向量模型来源"));
await user.click(screen.getByRole("option", { name: "本机 Ollama" }));
await user.type(
screen.getByLabelText("向量模型 Base URL"),
"http://127.0.0.1:11434",
@@ -836,7 +1257,7 @@ describe("ModelSettings", () => {
);
});
it("后端激活不了的协议根本不出现在下拉里", () => {
it("后端激活不了的协议显示但不可选择", () => {
render(<ModelSettings />);
const providerSelect = screen.getByLabelText("协议") as HTMLSelectElement;
@@ -849,12 +1270,16 @@ describe("ModelSettings", () => {
);
expect(labels.some((text) => text.startsWith("Ollama(本机)"))).toBe(true);
// ModelProfileStore.activate() 对这两个 provider 抛
// model_provider_not_supported。之前它们以 disabled 的形态留在下拉里,
// 等于把两条死路摆在明面上让人去读懂括号里的「暂不支持」。
expect(labels.some((text) => text.includes("Anthropic"))).toBe(false);
expect(labels.some((text) => text.includes("HY Memory"))).toBe(false);
expect(providerSelect.options.length).toBe(2);
expect(
Array.from(providerSelect.options).find((option) =>
option.textContent?.includes("Anthropic"),
)?.disabled,
).toBe(true);
expect(
Array.from(providerSelect.options).find((option) =>
option.textContent?.includes("HY 托管"),
)?.disabled,
).toBe(true);
});
it("已保存的配置不印内置向量模型", () => {
@@ -1043,6 +1468,87 @@ describe("ModelSettings", () => {
expect(nameField().disabled).toBe(false);
});
});
it("only completes compact setup after the backend confirms the profile is active", async () => {
const user = userEvent.setup();
const onSetupComplete = vi.fn();
const capabilities = {
endpoint_reachable: true,
model_available: true,
structured_output: false,
embedding_available: true,
};
const created = {
...profile,
profile_id: "created",
revision: 1,
capabilities,
};
profiles = [];
create.mockImplementation(async () => {
profiles = [created];
return created;
});
probeProfile.mockResolvedValue({
profile_id: created.profile_id,
profile_revision: created.revision,
stages: [],
capabilities,
});
activate.mockResolvedValue({ ...created, is_active: true });
render(<ModelSettings compactSingle onSetupComplete={onSetupComplete} />);
await user.type(screen.getByLabelText("配置名称"), "Current model");
await user.type(screen.getByLabelText("生成模型名"), "model-v1");
await user.type(screen.getByLabelText(/^API Key/), "secret-value");
await user.click(screen.getByRole("button", { name: "测试并完成配置" }));
await waitFor(() => expect(onSetupComplete).toHaveBeenCalledOnce());
expect(planActivation).toHaveBeenCalledWith(
created.profile_id,
created.revision,
);
expect(activate).toHaveBeenCalledWith(created.profile_id);
});
it("keeps compact setup open when activation is not confirmed", async () => {
const user = userEvent.setup();
const onSetupComplete = vi.fn();
const capabilities = {
endpoint_reachable: true,
model_available: true,
structured_output: false,
embedding_available: true,
};
const created = {
...profile,
profile_id: "created",
revision: 1,
capabilities,
};
profiles = [];
create.mockImplementation(async () => {
profiles = [created];
return created;
});
probeProfile.mockResolvedValue({
profile_id: created.profile_id,
profile_revision: created.revision,
stages: [],
capabilities,
});
activate.mockResolvedValue({ ...created, is_active: false });
render(<ModelSettings compactSingle onSetupComplete={onSetupComplete} />);
await user.type(screen.getByLabelText("配置名称"), "Inactive model");
await user.type(screen.getByLabelText("生成模型名"), "model-v1");
await user.type(screen.getByLabelText(/^API Key/), "secret-value");
await user.click(screen.getByRole("button", { name: "测试并完成配置" }));
await screen.findByText("配置已保存,尚未确认启用成功。请核对配置后重试。");
expect(onSetupComplete).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "测试并完成配置" })).toBeTruthy();
});
});
describe("把一张预置卡设为当前模型", () => {
File diff suppressed because it is too large. Load diff
@@ -8,7 +8,18 @@ import { completeOnboarding } from "./api";
import { OnboardingFlow } from "./OnboardingFlow";
import type { ImportSourcesController } from "./use-import-sources";
vi.mock("./api", () => ({ completeOnboarding: vi.fn() }));
vi.mock("./api", () => ({
completeOnboarding: vi.fn(),
getMemoryMode: vi.fn().mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: false,
}),
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
planMemoryMode: vi.fn().mockResolvedValue({ blockers: [] }),
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
}));
vi.mock("./FirstImportReview", () => ({
defaultHostSourceIds: () => [],
FirstImportReview: ({
@@ -41,6 +52,9 @@ const state = {
selected_source_ids: [],
started_at: null,
completed_at: null,
mode_intent: null,
setup_step: null,
model_profile_id: null,
} as const;
function source(
id: string,
@@ -121,10 +135,13 @@ 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(screen.getByRole("button", { name: "继续配置模型" }));
expect(screen.getByRole("heading", { name: "第三步模型页面" })).toBeTruthy();
expect(completeOnboarding).not.toHaveBeenCalled();
expect(finish).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: "返回第二步" }));
await userEvent.click(screen.getByRole("button", { name: "返回导入" }));
expect(screen.getByText("Import accepted")).toBeTruthy();
expect(completeOnboarding).not.toHaveBeenCalled();
});
@@ -147,6 +164,8 @@ it("lets an empty first-time user enter without granting or importing a source",
const skip = await screen.findByRole("button", { name: "稍后添加,先进入" });
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(screen.getByRole("button", { name: "使用 Lite 进入" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
@@ -168,6 +187,8 @@ it("retains explicit retry or enter when saving the skipped onboarding fails", a
const skip = await screen.findByRole("button", { name: "稍后添加,先进入" });
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(screen.getByRole("button", { name: "使用 Lite 进入" }));
await screen.findByRole("button", { name: "重试保存并进入" });
expect(finish).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: "仍进入应用页" }));
@@ -25,6 +25,15 @@ import type { ImportSourcesController } from "./use-import-sources";
vi.mock("./api", () => ({
completeOnboarding: vi.fn(),
getModelProfiles: vi.fn(),
getMemoryMode: vi.fn().mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: false,
}),
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
planMemoryMode: vi.fn().mockResolvedValue({ blockers: [] }),
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
}));
const models = {
pendingProbe: false,
@@ -38,6 +47,22 @@ const models = {
activate: vi.fn(),
};
vi.mock("./use-model-profiles", () => ({ useModelProfiles: () => models }));
vi.mock("./use-embedding-switch", () => ({
useEmbeddingSwitch: () => ({
current: null,
plan: null,
busy: null,
errorCode: null,
refreshFailed: false,
supported: false,
planActivation: vi.fn(),
startPlannedSwitch: vi.fn(),
cancelCurrentSwitch: vi.fn(),
shouldUseLegacyActivation: () => true,
clearPlan: vi.fn(),
clearError: vi.fn(),
}),
}));
const source: SourceResponse = {
source_id: "cursor",
display_name: "Cursor",
@@ -87,6 +112,9 @@ const state = {
selected_source_ids: [],
started_at: null,
completed_at: null,
mode_intent: null,
setup_step: null,
model_profile_id: null,
};
beforeEach(() => {
delete window.oneMemory;
@@ -243,7 +271,7 @@ it("translates import review and preserves independent permission disclosure", a
).toBeTruthy();
expect(
screen.getByText(
"Importar no concede automáticamente acceso de lectura a las aplicaciones. Las memorias comenzarán a generarse cuando configures un modelo.",
"A continuación, elige un modo de memoria. Podrás cambiarlo más adelante en Configuración.",
),
).toBeTruthy();
expect(
@@ -273,20 +301,20 @@ it("uses compact Spanish actions and preserves Chinese copy and permissions", as
expect(navigation.textContent).toContain("Conectar apps");
expect(
screen.getByRole("button", {
name: "Configurar un modelo para crear memorias",
name: "Elegir el modo de memoria",
}),
).toBeTruthy();
expect(screen.getByText(/Importar no concede automáticamente/)).toBeTruthy();
expect(
screen.getByText(/A continuación, elige un modo de memoria/),
).toBeTruthy();
expect(
screen.getByText(/Solo para las apps seleccionadas y compatibles/),
).toBeTruthy();
expect(screen.getByText(/Si una falla, las demás siguen/)).toBeTruthy();
fireEvent.click(screen.getByText("中文"));
expect(
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
).toBeTruthy();
expect(screen.getByRole("button", { name: "选择记忆模式" })).toBeTruthy();
expect(navigation.textContent).toContain("连接应用");
expect(screen.getByText(/不会自动开放应用读取权限/)).toBeTruthy();
expect(screen.getByText(/下一步选择记忆模式/)).toBeTruthy();
expect(next).not.toHaveBeenCalled();
expect(draft).not.toHaveBeenCalled();
});
@@ -393,6 +421,8 @@ it("translates completion recovery live without repeating completion writes", as
fireEvent.click(
screen.getByRole("button", { name: "Entrar y añadir más tarde" }),
);
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
fireEvent.click(screen.getByRole("button", { name: "Entrar con Lite" }));
expect(
await screen.findByRole("button", { name: "Reintentar guardar y entrar" }),
).toBeTruthy();
@@ -415,9 +445,14 @@ it("translates model configuration, validation and advanced fields without activ
);
expect(
await screen.findByRole("heading", {
name: "Elige un modelo para crear memorias",
name: "Configurar el modelo de organización de recuerdos",
}),
).toBeTruthy();
fireEvent.click(
screen.getByRole("button", {
name: "Detalles y configuración avanzada",
}),
);
fireEvent.click(screen.getByRole("button", { name: "Probar conexión" }));
expect(await screen.findByText("Introduce una API Key.")).toBeTruthy();
expect(screen.getByLabelText("Tiempo de espera (segundos)")).toBeTruthy();
@@ -428,6 +463,42 @@ it("translates model configuration, validation and advanced fields without activ
expect(screen.getByLabelText("超时(秒)")).toBeTruthy();
});
it.each([
[
"索引更新没有完成,仍在使用原来的搜索模型。请检查连接后重试。",
"No se completó la actualización del índice. Se sigue usando el modelo de búsqueda anterior. Comprueba la conexión y vuelve a intentarlo.",
],
[
"上次模型测试结果仍待核对,请稍后重试。",
"El resultado de la última prueba del modelo aún debe comprobarse. Vuelve a intentarlo más tarde.",
],
[
"模型写入暂时关闭。请等待索引维护完成后重试。",
"La escritura del modelo está temporalmente pausada. Espera a que termine el mantenimiento del índice y vuelve a intentarlo.",
],
[
"模型未能确认启用,请在设置中核对。",
"No se pudo confirmar que el modelo esté habilitado. Compruébalo en Configuración.",
],
[
"已保存的模型配置暂时无法读取,不会重复创建。请重试刷新。",
"La configuración guardada del modelo no se puede leer temporalmente. No se creará un duplicado. Vuelve a actualizar.",
],
])("recognizes controller message key %s", async (key, translated) => {
render(
<LanguageProvider>
<OnboardingModelController
sourceNames={[]}
flowError={key}
onBack={vi.fn()}
/>
</LanguageProvider>,
);
expect(await screen.findByText(translated)).toBeTruthy();
expect(screen.queryByText(key)).toBeNull();
});
it.each([
[
"completed",
@@ -26,6 +26,15 @@ vi.mock("./api", () => ({
getImportJobs: vi.fn(),
connectSources: vi.fn(),
completeOnboarding: vi.fn(),
getMemoryMode: vi.fn().mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: false,
}),
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
planMemoryMode: vi.fn().mockResolvedValue({ blockers: [] }),
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
getSourceConnection: vi.fn(),
createImportJob: vi.fn(),
detectSource: vi.fn(),
@@ -68,6 +77,9 @@ const state: OnboardingState = {
selected_source_ids: [],
started_at: null,
completed_at: null,
mode_intent: null,
setup_step: null,
model_profile_id: null,
};
let sources: SourceResponse[];
let jobs: ImportJob[];
@@ -140,6 +152,17 @@ function noUnrequestedWrites() {
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(api.getMemoryMode).mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: false,
} as never);
vi.mocked(api.setMemoryMode).mockResolvedValue({ mode: "pro" } as never);
vi.mocked(api.planMemoryMode).mockResolvedValue({
blockers: [],
} as never);
vi.mocked(api.putOnboardingProgress).mockResolvedValue(state);
vi.mocked(api.patchOnboardingProgress).mockResolvedValue(state);
localStorage.clear();
vi.stubGlobal("crypto", webcrypto);
delete window.oneMemory;
@@ -161,6 +184,34 @@ beforeEach(() => {
...state,
status: "completed",
});
vi.mocked(api.getMemoryMode).mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: false,
forced_by_env: false,
modes: ["lite", "pro", "ultra"],
graph_build: {
state: "idle",
started_at: null,
finished_at: null,
users: {},
},
});
vi.mocked(api.setMemoryMode).mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: true,
forced_by_env: false,
modes: ["lite", "pro", "ultra"],
graph_build: {
state: "idle",
started_at: null,
finished_at: null,
users: {},
},
previous_mode: "pro",
effects: [],
});
vi.mocked(api.connectSources).mockImplementation(
async (ids, _confirmed, options) => ({
request_id: options?.requestId ?? null,
@@ -267,6 +318,8 @@ it("empty discovery shows zero and allows explicit entry without a connection re
true,
);
fireEvent.click(screen.getByRole("button", { name: "稍后添加,先进入" }));
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
fireEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
@@ -317,6 +370,8 @@ it("partial acceptance preserves the successful job and failure through model-st
expect(screen.getByLabelText("失败来源").textContent).toBe("cursor");
expect(api.completeOnboarding).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "进入应用" }));
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
fireEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
+15 -1
View File
@@ -13,7 +13,18 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { OnboardingFlow } from "./OnboardingFlow";
import type { ImportSourcesController } from "./use-import-sources";
vi.mock("./api", () => ({ completeOnboarding: vi.fn() }));
vi.mock("./api", () => ({
completeOnboarding: vi.fn(),
getMemoryMode: vi.fn().mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: false,
}),
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
planMemoryMode: vi.fn().mockResolvedValue({ blockers: [] }),
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
}));
vi.mock("./FirstImportReview", () => ({
FirstImportReview: ({ sourceIds }: { sourceIds: string[] }) => (
<p>已受理 {sourceIds.join(",")}</p>
@@ -33,6 +44,9 @@ const state = {
selected_source_ids: [],
started_at: null,
completed_at: null,
mode_intent: null,
setup_step: null,
model_profile_id: null,
} as const;
function source(
id: string,
+471 -31
View File
@@ -9,6 +9,7 @@ import type {
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
@@ -17,6 +18,10 @@ import {
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "./api";
import {
confirmedMemoryModeSnapshot,
resetConfirmedMemoryModeForTests,
} from "./confirmed-memory-mode";
import { readConnectionPreferences } from "./connection-preferences";
import { OnboardingFlow, PLUGIN_CONSENT_KEY } from "./OnboardingFlow";
import {
@@ -25,6 +30,11 @@ import {
} from "./use-import-sources";
const completeOnboarding = vi.fn();
const setMemoryMode = vi.fn();
const getMemoryMode = vi.fn();
const planMemoryMode = vi.fn();
const putOnboardingProgress = vi.fn();
const patchOnboardingProgress = vi.fn();
const getModelProfiles = vi.fn();
const getMemoryProcessing = vi.fn();
const previewSourceDirectory = vi.fn();
@@ -44,6 +54,12 @@ vi.mock("./api", () => ({
getImportJob: vi.fn(),
apiErrorCode: vi.fn(() => null),
completeOnboarding: (...args: unknown[]) => completeOnboarding(...args),
setMemoryMode: (...args: unknown[]) => setMemoryMode(...args),
getMemoryMode: (...args: unknown[]) => getMemoryMode(...args),
planMemoryMode: (...args: unknown[]) => planMemoryMode(...args),
putOnboardingProgress: (...args: unknown[]) => putOnboardingProgress(...args),
patchOnboardingProgress: (...args: unknown[]) =>
patchOnboardingProgress(...args),
getModelProfiles: (...args: unknown[]) => getModelProfiles(...args),
getMemoryProcessing: (...args: unknown[]) => getMemoryProcessing(...args),
previewSourceDirectory: (...args: unknown[]) =>
@@ -58,20 +74,33 @@ vi.mock("./OnboardingModelController", () => ({
OnboardingModelController: ({
onBack,
onSkip,
onEnabled,
onProfilePersisted,
onModelActiveConfirmed,
generationToken,
}: {
onBack(): void;
onSkip(): void;
onEnabled(): void;
onSkip?(): void;
onProfilePersisted(profileId: string): void;
onModelActiveConfirmed(token: number, profileId: string): void;
generationToken: number;
}) => (
<section aria-label="模型步骤边界">
<button type="button" onClick={onBack}>
返回导入
</button>
<button type="button" onClick={onEnabled}>
<button
type="button"
onClick={() => {
onProfilePersisted("9be6d465-4cf2-4c72-93f7-15f3c012bc80");
onModelActiveConfirmed(
generationToken,
"9be6d465-4cf2-4c72-93f7-15f3c012bc80",
);
}}
>
模型启用回执已确认
</button>
<button type="button" onClick={onSkip}>
<button type="button" onClick={onSkip ?? onBack}>
跳过模型进入应用
</button>
</section>
@@ -83,6 +112,9 @@ const onboarding: OnboardingState = {
selected_source_ids: [],
started_at: null,
completed_at: null,
mode_intent: null,
setup_step: null,
model_profile_id: null,
};
const health = {
status: "ok" as const,
@@ -176,9 +208,18 @@ async function skipWelcome(user?: ReturnType<typeof userEvent.setup>) {
await openApps(user);
}
async function continueImport(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole("button", { name: "配置模型,沉淀记忆" }));
await openModelStep(user);
await user.click(screen.getByRole("button", { name: "模型启用回执已确认" }));
}
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 user.click(screen.getByRole("button", { name: "继续配置模型" }));
}
async function chooseLiteAndContinue(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole("radio", { name: /lite/i }));
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
}
function choice(name = "Claude Code") {
return screen.getByRole<HTMLInputElement>("checkbox", {
name: `导入 ${name} 历史`,
@@ -212,12 +253,40 @@ async function approve(user: ReturnType<typeof userEvent.setup>) {
describe("OnboardingFlow S1.3", () => {
beforeEach(() => {
vi.resetAllMocks();
resetConfirmedMemoryModeForTests();
window.localStorage.clear();
window.oneMemory = undefined;
completeOnboarding.mockResolvedValue({
...onboarding,
status: "completed",
});
setMemoryMode.mockResolvedValue({ mode: "pro" });
planMemoryMode.mockResolvedValue({
current: "lite",
target: "pro",
effective_target: "pro",
capabilities: {},
effects: [],
warnings: [],
blockers: [],
});
putOnboardingProgress.mockImplementation(
async (update: Partial<OnboardingState>) => ({
...onboarding,
...update,
}),
);
patchOnboardingProgress.mockImplementation(
async (update: Partial<OnboardingState>) => ({
...onboarding,
...update,
}),
);
getMemoryMode.mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: false,
});
});
afterEach(() => {
cleanup();
@@ -352,10 +421,10 @@ describe("OnboardingFlow S1.3", () => {
await user.click(screen.getByRole("button", { name: START }));
const continueButton = await screen.findByRole("button", {
name: "配置模型,沉淀记忆",
name: "选择记忆模式",
});
expect(continueButton).toHaveProperty("disabled", false);
await user.click(continueButton);
await openModelStep(user);
const modelBoundary = screen.getByRole("region", {
name: "模型步骤边界",
});
@@ -365,6 +434,7 @@ describe("OnboardingFlow S1.3", () => {
modelBoundary,
);
await user.click(screen.getByRole("button", { name: "返回导入" }));
await user.click(screen.getByRole("button", { name: "返回导入" }));
await waitFor(() =>
expect(screen.getByRole("alert").textContent).toContain(
"开始导入的结果尚未确认",
@@ -388,9 +458,7 @@ describe("OnboardingFlow S1.3", () => {
onFinish={finish}
/>,
);
await userEvent.click(
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
);
await openModelStep(userEvent.setup());
expect(screen.getByRole("region", { name: "模型步骤边界" })).toBeTruthy();
expect(completeOnboarding).not.toHaveBeenCalled();
expect(finish).not.toHaveBeenCalled();
@@ -649,9 +717,7 @@ describe("OnboardingFlow S1.3", () => {
await approve(user);
await user.click(screen.getByRole("button", { name: START }));
expect(onFinish).not.toHaveBeenCalled();
await user.click(
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
);
await openModelStep(user);
expect(onFinish).not.toHaveBeenCalled();
expect(completeOnboarding).not.toHaveBeenCalled();
await user.click(
@@ -678,13 +744,387 @@ describe("OnboardingFlow S1.3", () => {
);
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: "使用 Lite 进入" }));
expect(onFinish).toHaveBeenCalledWith("sources");
expect(completeOnboarding).toHaveBeenCalledOnce();
expect(controller.refresh).toHaveBeenCalledOnce();
expect(controller.connectForOnboarding).not.toHaveBeenCalled();
});
it("allows entering the app from step three without creating a model", async () => {
it("shows the compact Lite and Pro mode step with contextual actions", async () => {
const user = userEvent.setup();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={vi.fn()}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
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.getByText("推荐").closest("label")?.textContent).toContain(
"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 }));
expect(screen.getByRole("button", { name: "使用 Lite 进入" })).toBeTruthy();
await user.click(screen.getByRole("radio", { name: /pro/i }));
expect(screen.getByRole("button", { name: "继续配置模型" })).toBeTruthy();
});
it("does not let a late saved-mode read overwrite the user's new choice", async () => {
let resolveMode!: (value: {
mode: "pro";
configured_mode: "pro";
explicitly_configured: true;
}) => void;
getMemoryMode.mockReturnValueOnce(
new Promise((resolve) => {
resolveMode = 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 }));
const lite = screen.getByRole<HTMLInputElement>("radio", { name: /lite/i });
await user.click(lite);
await act(async () => {
resolveMode({
mode: "pro",
configured_mode: "pro",
explicitly_configured: true,
});
});
expect(lite.checked).toBe(true);
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
expect(setMemoryMode).toHaveBeenLastCalledWith("lite");
});
it("retains the explicit lite choice when saving the mode fails and retries", async () => {
const user = userEvent.setup();
const controller = imports({ sources: [] });
const onFinish = vi.fn();
setMemoryMode.mockRejectedValueOnce(new Error("offline"));
render(
<OnboardingFlow
state={onboarding}
imports={controller}
onFinish={onFinish}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
const lite = screen.getByRole<HTMLInputElement>("radio", { name: /lite/i });
expect(screen.getByRole("button", { name: "继续" })).toHaveProperty(
"disabled",
true,
);
await user.click(lite);
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
expect((await screen.findByRole("alert")).textContent).toContain(
"模式未能保存。你的选择已保留,请重试。",
);
expect(lite.checked).toBe(true);
expect(completeOnboarding).not.toHaveBeenCalled();
expect(onFinish).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledTimes(2));
expect(setMemoryMode).toHaveBeenLastCalledWith("lite");
expect(completeOnboarding).toHaveBeenCalledOnce();
expect(onFinish).toHaveBeenCalledWith("sources");
});
it("serializes an in-flight Pro PUT before a later Lite choice so Lite wins", async () => {
let resolvePro!: (value: { mode: "pro" }) => void;
setMemoryMode
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolvePro = resolve;
}),
)
.mockResolvedValueOnce({ mode: "lite" });
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 user.click(screen.getByRole("radio", { name: /pro/i }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("pro"));
await user.click(screen.getByRole("button", { name: "返回导入" }));
await user.click(screen.getByRole("radio", { name: /lite/i }));
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
expect(setMemoryMode).toHaveBeenCalledTimes(1);
await act(async () => resolvePro({ mode: "pro" }));
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledTimes(2));
expect(setMemoryMode.mock.calls.map(([mode]) => mode)).toEqual([
"pro",
"lite",
]);
await waitFor(() => expect(finish).toHaveBeenCalledWith("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(
() =>
new Promise((resolve) => {
resolvePro = 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: /pro/i }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
const revisionBeforePut = confirmedMemoryModeSnapshot().revision;
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("pro"));
await user.click(screen.getByRole("button", { name: "返回导入" }));
await act(async () => resolvePro({ mode: "pro" }));
await waitFor(() =>
expect(
screen.getByRole("heading", { name: "选择记忆模式" }),
).toBeTruthy(),
);
expect(confirmedMemoryModeSnapshot().revision).toBe(revisionBeforePut);
expect(completeOnboarding).not.toHaveBeenCalled();
});
it("serializes model and back progress writes so setup mode wins", async () => {
let resolveModelProgress!: (value: OnboardingState) => void;
putOnboardingProgress
.mockResolvedValueOnce({
...onboarding,
mode_intent: "pro",
setup_step: "model",
})
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveModelProgress = resolve;
}),
)
.mockResolvedValueOnce({
...onboarding,
mode_intent: "pro",
setup_step: "mode",
});
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: /pro/i }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
await waitFor(() => expect(putOnboardingProgress).toHaveBeenCalledTimes(2));
await user.click(screen.getByRole("button", { name: "返回导入" }));
expect(putOnboardingProgress).toHaveBeenCalledTimes(2);
await act(async () =>
resolveModelProgress({
...onboarding,
mode_intent: "pro",
setup_step: "model",
}),
);
await waitFor(() => expect(putOnboardingProgress).toHaveBeenCalledTimes(3));
expect(putOnboardingProgress).toHaveBeenLastCalledWith({
mode_intent: "pro",
setup_step: "mode",
model_profile_id: "9be6d465-4cf2-4c72-93f7-15f3c012bc80",
});
expect(completeOnboarding).not.toHaveBeenCalled();
});
it("moves focus to the mode title on entry and after returning from model", async () => {
const user = userEvent.setup();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={vi.fn()}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
const firstTitle = screen.getByRole("heading", { name: "选择记忆模式" });
await waitFor(() => expect(document.activeElement).toBe(firstTitle));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(screen.getByRole("button", { name: "返回导入" }));
const returnedTitle = screen.getByRole("heading", {
name: "选择记忆模式",
});
await waitFor(() => expect(document.activeElement).toBe(returnedTitle));
});
it("treats completed progress conflicts as terminal instead of retryable failures", async () => {
putOnboardingProgress.mockRejectedValueOnce(
Object.assign(new Error("completed"), {
response: {
status: 409,
data: { detail: "onboarding_completed" },
},
}),
);
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 user.click(screen.getByRole("radio", { name: /pro/i }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await waitFor(() => expect(finish).toHaveBeenCalledWith("sources"));
expect(screen.queryByRole("alert")).toBeNull();
expect(setMemoryMode).not.toHaveBeenCalled();
});
it("persists a late profile receipt after leaving model but fences late activation", async () => {
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: /pro/i }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
const lateReceipt = screen.getByRole("button", {
name: "模型启用回执已确认",
});
await user.click(screen.getByRole("button", { name: "返回导入" }));
fireEvent.click(lateReceipt);
await waitFor(() =>
expect(patchOnboardingProgress).toHaveBeenCalledWith({
model_profile_id: "9be6d465-4cf2-4c72-93f7-15f3c012bc80",
}),
);
expect(setMemoryMode).not.toHaveBeenCalled();
expect(completeOnboarding).not.toHaveBeenCalled();
});
it("restores a persisted Pro model step directly after restart", async () => {
render(
<OnboardingFlow
state={{
...onboarding,
mode_intent: "pro",
setup_step: "model",
model_profile_id: "9be6d465-4cf2-4c72-93f7-15f3c012bc80",
}}
imports={imports({ sources: [] })}
onFinish={vi.fn()}
/>,
);
expect(screen.getByRole("region", { name: "模型步骤边界" })).toBeTruthy();
expect(screen.queryByRole("heading", { name: "选择记忆模式" })).toBeNull();
});
it("keeps the Pro choice retryable when progress persistence fails", async () => {
putOnboardingProgress.mockRejectedValueOnce(new Error("offline"));
const user = userEvent.setup();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={vi.fn()}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
const pro = screen.getByRole<HTMLInputElement>("radio", { name: /pro/i });
await user.click(pro);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
expect((await screen.findByRole("alert")).textContent).toContain(
"模式未能保存",
);
expect(pro.checked).toBe(true);
expect(screen.queryByRole("region", { name: "模型步骤边界" })).toBeNull();
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
expect(
await screen.findByRole("region", { name: "模型步骤边界" }),
).toBeTruthy();
});
it("returns from skipped pro setup to an explicit mode choice", async () => {
const user = userEvent.setup();
const controller = imports();
const onFinish = vi.fn();
@@ -698,10 +1138,13 @@ describe("OnboardingFlow S1.3", () => {
await choose(user);
await approve(user);
await user.click(screen.getByRole("button", { name: START }));
await user.click(
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
);
await openModelStep(user);
await user.click(screen.getByRole("button", { name: "跳过模型进入应用" }));
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
expect(completeOnboarding).not.toHaveBeenCalled();
expect(onFinish).not.toHaveBeenCalled();
await user.click(screen.getByRole("radio", { name: /lite/i }));
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
expect(completeOnboarding).toHaveBeenCalledOnce();
expect(onFinish).toHaveBeenCalledWith("sources");
expect(getModelProfiles).not.toHaveBeenCalled();
@@ -721,6 +1164,7 @@ describe("OnboardingFlow S1.3", () => {
);
await choose(user);
await user.click(screen.getByRole("button", { name: SKIP }));
await chooseLiteAndContinue(user);
expect(onFinish).toHaveBeenCalledWith("sources");
for (const fn of [
controller.connectForOnboarding,
@@ -775,6 +1219,7 @@ describe("OnboardingFlow S1.3", () => {
expect(choice()).toHaveProperty("disabled", false);
await waitFor(() => expect(choice()).toHaveProperty("checked", true));
await user.click(screen.getByRole("button", { name: SKIP }));
await chooseLiteAndContinue(user);
expect(onFinish).toHaveBeenCalledWith("sources");
expect(controller.connectForOnboarding).not.toHaveBeenCalled();
});
@@ -798,6 +1243,7 @@ describe("OnboardingFlow S1.3", () => {
expect(controller.connectForOnboarding).not.toHaveBeenCalled();
expect(onFinish).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: SKIP }));
await chooseLiteAndContinue(user);
expect(onFinish).toHaveBeenCalledWith("sources");
});
@@ -1651,7 +2097,7 @@ describe("OnboardingFlow S1.3", () => {
});
await openApps();
await user.click(screen.getByRole("button", { name: START }));
await screen.findByRole("button", { name: "配置模型,沉淀记忆" });
await screen.findByRole("button", { name: "选择记忆模式" });
await continueImport(user);
expect(onFinish).toHaveBeenCalledWith("sources");
expect(api.connectSources).toHaveBeenCalledOnce();
@@ -1868,6 +2314,7 @@ describe("OnboardingFlow S1.3", () => {
await user.click(screen.getByRole("button", { name: START }));
expect(onFinish).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "查看未导入的应用" }));
await chooseLiteAndContinue(user);
expect(onFinish).toHaveBeenCalledWith("sources");
});
@@ -2042,6 +2489,7 @@ describe("OnboardingFlow S1.3", () => {
/>,
);
await user.click(screen.getByRole("button", { name: SKIP }));
await chooseLiteAndContinue(user);
await user.click(screen.getByRole("button", { name: "仍进入应用页" }));
expect(onFinish).toHaveBeenCalledWith("sources");
expect(controller.connectForOnboarding).not.toHaveBeenCalled();
@@ -2143,9 +2591,7 @@ describe("OnboardingFlow S1.3", () => {
expect(readConnectionPreferences().hostSourceIds).toContain("claude");
expect(controller.setSync).not.toHaveBeenCalled();
expect(previewRecallInstall).not.toHaveBeenCalled();
await user.click(
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
);
await openModelStep(user);
expect(screen.getByRole("region", { name: "模型步骤边界" })).toBeTruthy();
expect(controller.setSync).not.toHaveBeenCalled();
expect(previewRecallInstall).toHaveBeenCalledWith("claude-code");
@@ -2192,9 +2638,7 @@ describe("OnboardingFlow S1.3", () => {
await user.click(insertion);
expect(insertion.checked).toBe(false);
expect(readConnectionPreferences().hostSourceIds).toEqual([]);
await user.click(
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
);
await openModelStep(user);
expect(controller.setSync).not.toHaveBeenCalled();
expect(previewRecallInstall).not.toHaveBeenCalled();
});
@@ -2240,9 +2684,7 @@ describe("OnboardingFlow S1.3", () => {
});
expect(host.disabled).toBe(true);
expect(host.checked).toBe(false);
await user.click(
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
);
await user.click(screen.getByRole("button", { name: "选择记忆模式" }));
expect(controller.setSync).not.toHaveBeenCalled();
expect(previewRecallInstall).not.toHaveBeenCalled();
});
@@ -2355,9 +2797,7 @@ describe("OnboardingFlow S1.3", () => {
await waitFor(() => expect(insertion.disabled).toBe(false));
expect(previewRecallInstall).not.toHaveBeenCalled();
expect(insertion.checked).toBe(true);
await user.click(
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
);
await openModelStep(user);
expect(screen.getByRole("region", { name: "模型步骤边界" })).toBeTruthy();
expect(previewRecallInstall).toHaveBeenCalledWith("claude-code");
expect(window.oneMemory?.installRecall).toHaveBeenCalledTimes(1);
+472 -14
View File
@@ -15,8 +15,20 @@ import {
useState,
} from "react";
import { completeOnboarding } from "./api";
import {
apiErrorCode,
completeOnboarding,
getMemoryMode,
patchOnboardingProgress,
planMemoryMode,
putOnboardingProgress,
setMemoryMode,
} from "./api";
import { AppMark } from "./app-mark";
import {
commitConfirmedMemoryMode,
issueConfirmedModeTicket,
} from "./confirmed-memory-mode";
import { saveConnectionPreferences } from "./connection-preferences";
import {
defaultHostSourceIds,
@@ -28,6 +40,7 @@ import {
hostCapabilities,
hostWriteReason,
} from "./host-state";
import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
import { OnboardingModelController } from "./OnboardingModelController";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6ColdStartFrame } from "./R6ColdStartFrame";
@@ -38,6 +51,7 @@ import { isDefiniteRefusal } from "./write-outcome";
import "./cold-start.css";
export type OnboardingDestination = "memory" | "sources" | "settings";
type OnboardingMemoryMode = "lite" | "pro";
// Cold start neither reads nor writes installation intent.
export const PLUGIN_CONSENT_KEY = "one-memory.consent.plugins";
@@ -46,6 +60,28 @@ const SCOPE_CHANGED =
"来源位置、身份或状态已变化,请重新选择并核对。尚未提交本次授权。";
type LocationCheck = "idle" | "checking" | "ready" | "failed";
function isCompletedProgressConflict(reason: unknown): boolean {
if (apiErrorCode(reason) === "onboarding_completed") return true;
if (!reason || typeof reason !== "object" || !("response" in reason))
return false;
const response = (
reason as {
response?: { status?: unknown; data?: { detail?: unknown } };
}
).response;
return (
response?.status === 409 && response.data?.detail === "onboarding_completed"
);
}
function isGenerationInvalid(reason: unknown): boolean {
return (
reason instanceof Error &&
(reason.message === "onboarding_generation_invalid" ||
reason.message === "onboarding_progress_generation_invalid")
);
}
function defaultConnectionDraft(
sources: SourceResponse[],
sourceIds: readonly string[],
@@ -88,8 +124,37 @@ export function OnboardingFlow({
const [startingImport, setStartingImport] = useState<SourceResponse[] | null>(
null,
);
const [modelStep, setModelStep] = useState(false);
const [visitedModel, setVisitedModel] = useState(false);
const resumedStep =
state.mode_intent === "pro" && state.setup_step === "model"
? "model"
: state.mode_intent
? "mode"
: "review";
const [setupStep, setSetupStep] = useState<"review" | "mode" | "model">(
resumedStep,
);
const [modeChoice, setModeChoice] = useState<OnboardingMemoryMode | null>(
state.mode_intent,
);
const [modeDestination, setModeDestination] =
useState<OnboardingDestination>("sources");
const [modeSaving, setModeSaving] = useState(false);
const [modeError, setModeError] = useState<string | null>(null);
const [modeFocusToken, setModeFocusToken] = useState(
resumedStep === "mode" ? 1 : 0,
);
const modeChoiceTouched = useRef(state.mode_intent !== null);
const [visitedSetup, setVisitedSetup] = useState(resumedStep !== "review");
const [visitedModel, setVisitedModel] = useState(resumedStep === "model");
const [modelGenerationToken, setModelGenerationToken] = useState(
resumedStep === "model" ? 1 : 0,
);
const modelGeneration = useRef(resumedStep === "model" ? 1 : 0);
const intentRef = useRef<OnboardingMemoryMode | null>(state.mode_intent);
const setupStepRef = useRef<"review" | "mode" | "model">(resumedStep);
const modelProfileId = useRef<string | null>(state.model_profile_id);
const modeWriteQueue = useRef<Promise<void>>(Promise.resolve());
const progressWriteQueue = useRef<Promise<void>>(Promise.resolve());
const [modelSourceIds, setModelSourceIds] = useState<string[]>([]);
const [connectionDraft, setConnectionDraft] = useState<ImportConnectionDraft>(
{ hostSourceIds: [] },
@@ -135,7 +200,7 @@ export function OnboardingFlow({
const consideredSources = useRef(new Set<string>());
const heading = useRef<HTMLHeadingElement>(null);
const reviewingImport =
visitedModel ||
(visitedSetup && !completionFailed) ||
accepted !== null ||
startingImport !== null ||
state.status === "importing";
@@ -307,6 +372,23 @@ export function OnboardingFlow({
useEffect(() => {
alive.current = true;
const modeTicket = issueConfirmedModeTicket("onboarding");
void getMemoryMode().then(
(memoryMode) => {
if (alive.current)
commitConfirmedMemoryMode(memoryMode.mode, modeTicket);
if (
alive.current &&
!modeChoiceTouched.current &&
memoryMode.explicitly_configured &&
memoryMode.configured_mode !== "ultra"
)
setModeChoice(memoryMode.configured_mode);
},
() => {
// The choice screen remains usable; saving rechecks Core.
},
);
return () => {
alive.current = false;
};
@@ -401,6 +483,93 @@ export function OnboardingFlow({
[onFinish],
);
const enterModelStep = useCallback(() => {
intentRef.current = "pro";
setupStepRef.current = "model";
const token = ++modelGeneration.current;
setModelGenerationToken(token);
setVisitedModel(true);
setSetupStep("model");
}, []);
const leaveModelStep = useCallback(
(nextIntent: OnboardingMemoryMode, nextStep: "review" | "mode") => {
// This fence is synchronous: hidden model work must observe invalidation
// before React renders the replacement step.
modelGeneration.current += 1;
intentRef.current = nextIntent;
setupStepRef.current = nextStep;
setModeChoice(nextIntent);
setSetupStep(nextStep);
if (nextStep === "mode") setModeFocusToken((value) => value + 1);
setModeSaving(false);
},
[],
);
const queueModeWrite = useCallback(
(mode: OnboardingMemoryMode, allowed: () => boolean = () => true) => {
let resolveResult!: (
value: Awaited<ReturnType<typeof setMemoryMode>>,
) => void;
let rejectResult!: (reason?: unknown) => void;
const result = new Promise<Awaited<ReturnType<typeof setMemoryMode>>>(
(resolve, reject) => {
resolveResult = resolve;
rejectResult = reject;
},
);
modeWriteQueue.current = modeWriteQueue.current
.catch(() => undefined)
.then(async () => {
if (!allowed()) {
rejectResult(new Error("onboarding_generation_invalid"));
return;
}
try {
resolveResult(await setMemoryMode(mode));
} catch (reason) {
rejectResult(reason);
}
});
return result;
},
[],
);
const queueProgressWrite = useCallback(
(
operation: () => ReturnType<typeof putOnboardingProgress>,
allowed: () => boolean = () => true,
) => {
let resolveResult!: (
value: Awaited<ReturnType<typeof putOnboardingProgress>>,
) => void;
let rejectResult!: (reason?: unknown) => void;
const result = new Promise<
Awaited<ReturnType<typeof putOnboardingProgress>>
>((resolve, reject) => {
resolveResult = resolve;
rejectResult = reject;
});
progressWriteQueue.current = progressWriteQueue.current
.catch(() => undefined)
.then(async () => {
if (!allowed()) {
rejectResult(new Error("onboarding_progress_generation_invalid"));
return;
}
try {
resolveResult(await operation());
} catch (reason) {
rejectResult(reason);
}
});
return result;
},
[],
);
const persistFinish = useCallback(
async (destination: OnboardingDestination) => {
setExitDestination(destination);
@@ -420,8 +589,10 @@ export function OnboardingFlow({
: "onboarding_complete_result_unknown",
});
if (alive.current) {
modelGeneration.current += 1;
setupStepRef.current = "review";
setCompletionFailed(true);
setModelStep(false);
setSetupStep("review");
// A definite refusal means it was not saved; anything else (timeout,
// 5xx, no response) leaves the outcome unknown, and the copy must not
// assert a failure the UI cannot prove.
@@ -450,6 +621,163 @@ export function OnboardingFlow({
[persistFinish],
);
const requestModeChoice = useCallback(
(destination: OnboardingDestination) => {
if (finished.current) return;
if (setupStepRef.current === "model") leaveModelStep("pro", "mode");
else {
intentRef.current = modeChoice;
setupStepRef.current = "mode";
setSetupStep("mode");
setModeFocusToken((value) => value + 1);
}
setModeDestination(destination === "settings" ? "sources" : destination);
setModeError(null);
setError(null);
setVisitedSetup(true);
},
[leaveModelStep, modeChoice],
);
const saveModeChoice = useCallback(async () => {
if (
!modeChoice ||
modeSaving ||
finished.current ||
(modeChoice === "lite" && flight.current)
)
return;
setModeSaving(true);
setModeError(null);
const modeTicket = issueConfirmedModeTicket("onboarding");
try {
if (modeChoice === "pro") {
intentRef.current = "pro";
setupStepRef.current = "mode";
const selectionStillCurrent = () =>
alive.current &&
!finished.current &&
intentRef.current === "pro" &&
setupStepRef.current === "mode";
const savedProgress = await queueProgressWrite(
() =>
putOnboardingProgress({
mode_intent: "pro",
setup_step: "model",
model_profile_id: modelProfileId.current,
}),
selectionStillCurrent,
);
if (!selectionStillCurrent()) return;
modelProfileId.current = savedProgress.model_profile_id;
enterModelStep();
return;
}
leaveModelStep("lite", "mode");
const liteStillCurrent = () =>
alive.current &&
!finished.current &&
intentRef.current === "lite" &&
setupStepRef.current === "mode";
await queueProgressWrite(
() =>
putOnboardingProgress({
mode_intent: "lite",
setup_step: "mode",
model_profile_id: null,
}),
liteStillCurrent,
);
if (!alive.current) return;
const changed = await queueModeWrite("lite");
if (!alive.current) return;
commitConfirmedMemoryMode(changed.mode, modeTicket);
await leave(modeDestination);
} catch (reason) {
if (isCompletedProgressConflict(reason)) {
finishOnce(modeDestination);
} else if (alive.current) {
setModeError("模式未能保存。你的选择已保留,请重试。");
}
} finally {
if (alive.current) setModeSaving(false);
}
}, [
enterModelStep,
leave,
leaveModelStep,
modeChoice,
modeDestination,
modeSaving,
finishOnce,
queueModeWrite,
queueProgressWrite,
]);
const persistModelProfile = useCallback(
async (profileId: string) => {
modelProfileId.current = profileId;
try {
await queueProgressWrite(() =>
patchOnboardingProgress({ model_profile_id: profileId }),
);
} catch (reason) {
if (!isCompletedProgressConflict(reason) && alive.current)
setModeError("模型配置已保存,但恢复进度未能保存。请重试当前步骤。");
}
},
[queueProgressWrite],
);
const confirmProMode = useCallback(
async (token: number, ownerProfileId: string) => {
const ownsModelStep = () =>
alive.current &&
!finished.current &&
modelGeneration.current === token &&
intentRef.current === "pro" &&
setupStepRef.current === "model" &&
modelProfileId.current === ownerProfileId;
if (!ownsModelStep()) return;
setModeSaving(true);
setModeError(null);
const modeTicket = issueConfirmedModeTicket("onboarding");
try {
const plan = await planMemoryMode("pro");
if (!ownsModelStep()) return;
if (plan.blockers.length > 0) throw new Error(plan.blockers.join(","));
const changed = await queueModeWrite("pro", ownsModelStep);
if (!ownsModelStep()) return;
commitConfirmedMemoryMode(changed.mode, modeTicket);
await queueProgressWrite(
() =>
putOnboardingProgress({
mode_intent: "pro",
setup_step: "model",
model_profile_id: ownerProfileId,
}),
ownsModelStep,
);
if (!ownsModelStep()) return;
await persistFinish(modeDestination);
} catch (reason) {
if (isCompletedProgressConflict(reason)) {
finishOnce(modeDestination);
} else if (alive.current && !isGenerationInvalid(reason))
setModeError("Pro 模式未能确认保存。模型配置已保留,请重试。");
} finally {
if (alive.current) setModeSaving(false);
}
},
[
finishOnce,
modeDestination,
persistFinish,
queueModeWrite,
queueProgressWrite,
],
);
async function checkLocations() {
if (flight.current || locked) return;
flight.current = true;
@@ -636,7 +964,7 @@ export function OnboardingFlow({
return (
<>
<div
hidden={modelStep}
hidden={setupStep !== "review"}
data-import-submission={startingImport ? "pending" : "settled"}
>
<FirstImportReview
@@ -663,6 +991,10 @@ export function OnboardingFlow({
error={error && text(error)}
onContinue={(destination) => {
if (busy === "leaving") return;
if (completionFailed) {
void leave(destination);
return;
}
const selection = {
hostSourceIds: hostConnectionAvailable
? [...activeDraft.hostSourceIds]
@@ -676,9 +1008,8 @@ export function OnboardingFlow({
setApplyTick((value) => value + 1);
if (destination === "settings") {
setModelSourceIds([...sourceIds]);
setVisitedModel(true);
setModelStep(true);
} else void leave(destination);
}
requestModeChoice(destination);
}}
recovery={
completionFailed && exitDestination
@@ -690,14 +1021,129 @@ 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"
>
<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) => {
modeChoiceTouched.current = true;
setModeChoice(mode as OnboardingMemoryMode);
setModeError(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>
)}
{visitedModel && (
<div hidden={!modelStep}>
<div hidden={setupStep !== "model"}>
<OnboardingModelController
generationToken={modelGenerationToken}
isGenerationCurrent={(token) =>
modelGeneration.current === token &&
intentRef.current === "pro" &&
setupStepRef.current === "model"
}
initialProfileId={modelProfileId.current}
flowError={modeError}
connectionAlreadyApplied={
connectionApplication === "applied" ||
connectionApplication === "partial"
}
onEnabled={() => void leave("sources")}
onProfilePersisted={(profileId) =>
void persistModelProfile(profileId)
}
onModelActiveConfirmed={(token, ownerProfileId) =>
void confirmProMode(token, ownerProfileId)
}
sourceNames={sourceIds.map(
(id) =>
imports.sources.find((source) => source.source_id === id)
@@ -709,8 +1155,20 @@ export function OnboardingFlow({
(total, source) => total + source.imported_conversations,
0,
)}
onBack={() => setModelStep(false)}
onSkip={() => void leave("sources")}
onBack={() => {
setModeError(null);
leaveModelStep("pro", "mode");
void queueProgressWrite(() =>
putOnboardingProgress({
mode_intent: "pro",
setup_step: "mode",
model_profile_id: modelProfileId.current,
}),
).catch((reason) => {
if (!isCompletedProgressConflict(reason) && alive.current)
setModeError("恢复进度未能保存。你的选择已保留,请重试。");
});
}}
/>
</div>
)}
@@ -763,7 +1221,7 @@ export function OnboardingFlow({
<R6Button
variant="quiet"
disabled={locked}
onClick={() => void leave("sources")}
onClick={() => requestModeChoice("sources")}
>
{text("稍后添加,先进入")}
</R6Button>
Loaded 100 of 203 files, more files were not shown because too many files have changed in this diff. Show more