Compare commits

...
Author SHA1 Message Date
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 a218a92d4e fix(memhub): memory writes retry themselves; a broken model provider pauses the queue instead of losing jobs
The SDK keeps one outcome per request id and replays it: a write that failed
under an id fails under it forever, without calling the model. Re-running a
job under its idempotency key (including the old manual retry) therefore never
reached the model again. Every re-run now gets an SDK id of its own:
`idempotency_key` on attempt 0 (unchanged for existing jobs), then
`idempotency_key#rN`, N stored on the job (`sdk_attempt`, migration 61). Before
a re-run the outcome of the previous id is checked; a write that did succeed is
completed from its receipt and not made twice.

- Provider failure (401/403, 404, 408, 429, 5xx, timeout, connection error,
  breaker refusal) during the attempt: the job goes back to `pending` as it
  was: same place in the queue, no attempt or retry spent, no cap. The breaker
  paces it. Attribution: `ModelBackoff.attempt()` charges every provider
  failure to every write in flight, because a request cannot be traced to its
  job (the SDK batches embeddings on one shared worker).
- Any other failure: retried after 30 s, 60 s, 120 s, then `failed` with its
  code; the queue moves on.
- Crash or interruption mid-write (`unknown`): re-run automatically under a
  new id after the outcome check, counted as a retry. Existing `unknown` jobs
  are handled the same way after upgrade, once. A write still running on its
  thread in this process is left until it ends.
- The manual retry (`POST /api/v1/memory-processing/retry-unknown`, the App's
  重新整理 button and its strings) is removed.
- App banner while the provider is paused, in 11 locales; `error_code` and
  `next_retry_at` on `GET /api/v2/memory-writes/{id}` and
  `GET /api/v2/memories/additions/{id}`; `memhub status` shows the pause.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 16:58:23 +08:00
alvinfeiandClaude Opus 5.5 6845adf083 fix(memhub): WAL stays bounded, disabled sources suspend their jobs, recall no longer starves behind stuck writes
WAL (#245): the writer sets journal_size_limit (64 MiB), Core runs a
TRUNCATE checkpoint when the main database is idle (every minute, with a
50 ms busy wait, skipped rather than raised when busy) and at shutdown, and
the size alert judges pending frames rather than the file's size.

Disabled sources (#245): jobs of a source that is off or revoked are
suspended. Rows keep their state; status counts their waiting jobs as
`suspended` and queue depths leave them out. The claim gate already skips
them, so turning the source back on resumes them.

Gate agreement: a job the execute gate hands back (release_unstarted) is
held for 5 s through its lease, so a persistent disagreement between the
claim gate (lane user) and the execute gate (Evidence user) cannot spin.

Recall (#231): SDK writes run in their own bounded pool (12), recall's SDK
reads in theirs (8). The recall route has a total budget (timeout + 3 s,
at most 12 s) and answers 504 recall_timeout. The broker maps a Core
timeout to 504 and the client maps 504/ECONNABORTED/ETIMEDOUT to
broker_timeout. retry-unknown requeues off the event loop (#162).

Model backoff: a process-wide breaker per model profile revision records
every provider exchange through the SDK transport. After a provider failure
(auth, not found, rate limit, timeout, 5xx, unreachable) the drain claims no
jobs for 5 s, then one probe; each failed probe doubles the pause up to
3 minutes. The first success, or any model profile create, save, activation
or embedding switch, resets it. memory-processing reports
llm_backoff_until and llm_backoff_error.

Fixes #245, Fixes #231, Refs #162

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 16:58:23 +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
feitianxiang 850c728c9d Merge pull request #280 from Tencent-Hunyuan/fix/multi-user-hardening
fix(memhub): multi-user responses echo user_id, cross-user delete answers 404, Hermes profiles isolated for any root
2026-11-02 16:44:04 +08:00
feitianxiang 34f406e96d Merge pull request #277 from Tencent-Hunyuan/feat/memhub-hy-memory-2.0.12
feat(memhub): hy-memory 2.0.12; intention dedup switch in memory features
2026-11-02 15:16:36 +08:00
alvinfeiandClaude Opus 5.5 a653bd4065 feat(memhub): intention dedup switch in memory features
Expose hy-memory 2.0.12's per-client intention dedup in the memory-features
setting: the feature intention_dedup (recall.intention_dedup_enabled, default
on) and the parameter intention_dedup_threshold
(recall.intention_dedup_threshold, default 0.95, accepted 0.8-1.0, else 422).
Both are part of the features key, so a change rebuilds the SDK clients like
the other switches. CLI: memhub settings features set intention_dedup=false,
intention_dedup_threshold=0.9. Refs #241.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 15:04:24 +08:00
alvinfeiandClaude Opus 5.5 07b426ba7f chore(memhub): pin hy-memory 2.0.12
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 15:04:24 +08:00
feitianxiang 4b83b79b2b Merge pull request #275 from Tencent-Hunyuan/release/hy-memory-2.0.12
release: hy-memory 2.0.12
2026-11-02 14:58:42 +08:00
alvinfeiandClaude Opus 5.5 675cc30e5a release: hy-memory 2.0.12
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 14:30:15 +08:00
149 changed files with 13294 additions and 2278 deletions

No files matched your search

+117 -55
View File
@@ -4,58 +4,66 @@ 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.13 — 2026-11-02
- 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.
- 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.
- SDK: Intention dedup is now configurable per client
## Hy Memory 2.0.12 — 2026-11-02
- Intention dedup is now configurable per client
(`MemoryConfig.recall.intention_dedup_enabled`, default `true`;
`recall.intention_dedup_threshold`, default `MEMORY_DEDUP_THRESHOLD` or
`0.95`). Env defaults: `MEMORY_INTENTION_DEDUP_ENABLED`,
@@ -64,16 +72,28 @@ Hy Memory and MemHub are versioned and released independently.
dedup runs, at read time or within a write batch, and nothing is hidden,
dropped or deleted. The threshold applies to read-time dedup only. Refs #241.
- MemHub pins `hy-memory==2.0.12` and exposes that intention dedup switch in
its memory-features setting (`GET`/`PUT /api/v1/settings/memory-features`,
`memhub settings features`): the feature `intention_dedup` (default on) and
the numeric `intention_dedup_threshold` (default 0.95, accepted 0.8–1.0,
anything else is 422 or a CLI usage error). Unset, both keep the SDK
default, so nothing changes for the App; a change rebuilds the SDK clients
like the other feature switches. The API answer gains `parameters`, and
`pipeline.features` can now carry the threshold as a number. Refs #241.
## MemHub 1.0.6 — 2026-11-02
Build 37, on `hy-memory==2.0.11`. Linux x64/arm64 Runtime and macOS arm64
Build 38, 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
@@ -86,10 +106,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(
+4 -1
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",
@@ -3032,7 +3033,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,
+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.11`。MemHub 源码版本为 `1.0.6`
(build 37),GitHub 仓库为
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.13`。MemHub 源码版本为 `1.0.6`
(build 38),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.11`。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.11",
"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.11
## hy-memory 2.0.13
License: Apache-2.0
+4
View File
@@ -4383,6 +4383,10 @@ function renderWriteReceiptText(receipt: MemoryWriteReceipt): string {
...(receipt.memory_ids && receipt.memory_ids.length > 0
? [`memory_ids: ${receipt.memory_ids.join(",")}`]
: []),
...(receipt.error_code ? [`error_code: ${receipt.error_code}`] : []),
...(receipt.next_retry_at
? [`next_retry_at: ${receipt.next_retry_at}`]
: []),
"",
].join("\n");
}
+3 -2
View File
@@ -461,11 +461,12 @@ export const COMMANDS: readonly CommandDescriptor[] = [
{
name: "settings",
summary:
"查看或切换记忆模式(lite / pro / ultra)。lite 不调用模型,只在原始对话里检索;pro 由模型提取记忆(System 1);ultra 在 pro 之上每晚自动进化(System 2,使用知识图谱)。任何模式都保留全部原始对话。切换不迁移数据:已处理的不会重做,排队中的和新的按新模式处理;需 --yes,不加时只显示计划。环境变量 ONE_MEMORY_GRAPH_STORE 会强制 ultra。settings features 查看或开关 SDK 的可选检索与提取功能(全局设置,默认全部保持 SDK 默认值):graph_rerank 图谱重排(仅 ultra)、same_series_timeline 同系列时间线(需 graph_rerank)、episode_recall 片段召回(pro / ultra,需先开 session_episodes 写入片段)、session_episodes 片段写入、episode_summary 片段摘要(每个片段一次模型调用)、intention 意图提取(默认开)、few_shot 提取示例;set 需 --yes,不加时只显示计划;reset 恢复默认值。settings language 查看或设置界面语言(App 切换语言时会自动设置):简体中文(zh-Hans)和 system 不指定 SDK 语言,SDK 照旧自动识别中文或英文;其余语言(en、zh-Hant、ja、es、de、pt、fr、ko、ru、id)会让提取等 SDK 提示使用该语言,立即生效。",
"查看或切换记忆模式(lite / pro / ultra)。lite 不调用模型,只在原始对话里检索;pro 由模型提取记忆(System 1);ultra 在 pro 之上每晚自动进化(System 2,使用知识图谱)。任何模式都保留全部原始对话。切换不迁移数据:已处理的不会重做,排队中的和新的按新模式处理;需 --yes,不加时只显示计划。环境变量 ONE_MEMORY_GRAPH_STORE 会强制 ultra。settings features 查看或开关 SDK 的可选检索与提取功能(全局设置,默认全部保持 SDK 默认值):graph_rerank 图谱重排(仅 ultra)、same_series_timeline 同系列时间线(需 graph_rerank)、episode_recall 片段召回(pro / ultra,需先开 session_episodes 写入片段)、session_episodes 片段写入、episode_summary 片段摘要(每个片段一次模型调用)、intention 意图提取(默认开)、intention_dedup 意图判重(默认开;关闭则检索与写入都不判重、不删除意图)、few_shot 提取示例,以及数值 intention_dedup_threshold 意图判重阈值(默认 0.95,可设 0.8–1.0);set 需 --yes,不加时只显示计划;reset 恢复默认值。settings language 查看或设置界面语言(App 切换语言时会自动设置):简体中文(zh-Hans)和 system 不指定 SDK 语言,SDK 照旧自动识别中文或英文;其余语言(en、zh-Hant、ja、es、de、pt、fr、ko、ru、id)会让提取等 SDK 提示使用该语言,立即生效。",
usage:
"memhub settings mode [lite|pro|ultra] [--yes] [--json]\n" +
" memhub settings features [--json]\n" +
" memhub settings features set <name>=<true|false> [...] [--yes] [--json]\n" +
" memhub settings features set intention_dedup_threshold=<0.8-1.0> [--yes] [--json]\n" +
" memhub settings features reset [<name> ...] [--json]\n" +
" memhub settings language [<code>|system] [--json]",
options: [
@@ -476,7 +477,7 @@ export const COMMANDS: readonly CommandDescriptor[] = [
{
flag: "features set <name>=<true|false>",
summary:
"开关一个或多个 SDK 功能;功能在当前模式下不生效时会注明原因(例如 graph_rerank 需要 ultra)。",
"开关一个或多个 SDK 功能;功能在当前模式下不生效时会注明原因(例如 graph_rerank 需要 ultra)。intention_dedup_threshold 取 0.8–1.0 的数值。",
},
{
flag: "features reset [<name> ...]",
+5
View File
@@ -268,6 +268,11 @@ export interface MemoryWriteReceipt {
detail?: string;
raw_ids?: string[];
memory_ids?: string[];
/** Stable code of the last failure while the write still waits; null when
* none. Optional so older Runtimes stay readable. */
error_code?: string | null;
/** When Core will try this write again (ISO); null when not scheduled. */
next_retry_at?: string | null;
}
export interface OpenAIMessage {
+25
View File
@@ -233,6 +233,31 @@ describe("memories add-status", () => {
});
});
it("passes the retry code and time of an addition still waiting", async () => {
transport.get.mockResolvedValue({
status: 200,
data: {
...addReceipt("pending"),
error_code: "model_auth_failed",
next_retry_at: "2026-11-02T10:00:00+00:00",
},
});
const json = await capturedRun([
"memories",
"add-status",
REQUEST,
"--json",
]);
expect(JSON.parse(json.stdout).receipt).toMatchObject({
state: "pending",
error_code: "model_auth_failed",
next_retry_at: "2026-11-02T10:00:00+00:00",
});
const text = await capturedRun(["memories", "add-status", REQUEST]);
expect(text.stdout).toContain("error_code: model_auth_failed");
expect(text.stdout).toContain("next_retry_at: 2026-11-02T10:00:00+00:00");
});
it("reports an unknown request id as not_found, exit 1", async () => {
transport.get.mockResolvedValue({
status: 404,
+173 -6
View File
@@ -36,15 +36,17 @@ const DEFAULTS: Record<string, boolean> = {
session_episodes: false,
episode_summary: false,
intention: true,
intention_dedup: true,
few_shot: false,
};
const THRESHOLD_DEFAULT = 0.95;
function featuresState(
configured: Record<string, boolean> = {},
configured: Record<string, boolean | number> = {},
mode = "pro",
extra: Record<string, unknown> = {},
) {
const nonDefault: Record<string, boolean> = {};
const nonDefault: Record<string, boolean | number> = {};
const features = Object.entries(DEFAULTS).map(([name, fallback]) => {
const enabled = configured[name] ?? fallback;
if (enabled !== fallback) nonDefault[name] = enabled;
@@ -70,7 +72,34 @@ function featuresState(
description: name,
};
});
return { mode, features, non_default: nonDefault, ...extra };
const threshold =
(configured.intention_dedup_threshold as number | undefined) ??
THRESHOLD_DEFAULT;
if (threshold !== THRESHOLD_DEFAULT)
nonDefault.intention_dedup_threshold = threshold;
const dedupOn =
(configured.intention_dedup as boolean | undefined) ??
DEFAULTS.intention_dedup;
const parameters = [
{
name: "intention_dedup_threshold",
value: threshold,
configured:
"intention_dedup_threshold" in configured
? configured.intention_dedup_threshold
: null,
default: THRESHOLD_DEFAULT,
minimum: 0.8,
maximum: 1,
active: dedupOn,
reason: dedupOn ? null : "requires_intention_dedup",
requires_feature: "intention_dedup",
sdk_field: "recall.intention_dedup_threshold",
env: "MEMORY_INTENTION_DEDUP_THRESHOLD",
description: "threshold",
},
];
return { mode, features, parameters, non_default: nonDefault, ...extra };
}
beforeEach(() => {
@@ -110,11 +139,16 @@ describe("memhub settings features", () => {
expect(result.code).toBe(0);
const json = JSON.parse(result.stdout);
expect(json.state).toBe("ok");
expect(json.features.features).toHaveLength(7);
expect(json.features.features).toHaveLength(8);
expect(json.features.parameters[0].name).toBe("intention_dedup_threshold");
expect(transport.put).not.toHaveBeenCalled();
const text = (await capturedRun(["settings", "features"])).stdout;
expect(text).toContain("graph_rerank:关(默认)");
expect(text).toContain("intention:开(默认)");
expect(text).toContain("intention_dedup:开(默认)");
expect(text).toContain(
"intention_dedup_threshold:0.95(默认,可设 0.8–1)",
);
expect(text).toContain("先开启 session_episodes");
});
@@ -160,6 +194,130 @@ describe("memhub settings features", () => {
);
});
it("turns intention dedup off and sets its threshold, round trip", async () => {
transport.put.mockResolvedValueOnce({
status: 200,
data: featuresState({ intention_dedup: false }, "pro", {
changed: ["intention_dedup"],
}),
});
const off = await capturedRun([
"settings",
"features",
"set",
"intention_dedup=false",
"--yes",
"--json",
]);
expect(off.code).toBe(0);
expect(transport.put).toHaveBeenLastCalledWith(
"/api/v1/settings/memory-features",
{ intention_dedup: false },
undefined,
);
const offJson = JSON.parse(off.stdout);
expect(offJson.state).toBe("committed");
expect(offJson.changed).toEqual(["intention_dedup"]);
expect(offJson.features.parameters[0].reason).toBe(
"requires_intention_dedup",
);
transport.get.mockResolvedValue({
status: 200,
data: featuresState({ intention_dedup: false }),
});
transport.put.mockResolvedValueOnce({
status: 200,
data: featuresState(
{ intention_dedup: true, intention_dedup_threshold: 0.9 },
"pro",
{ changed: ["intention_dedup", "intention_dedup_threshold"] },
),
});
const on = await capturedRun([
"settings",
"features",
"set",
"intention_dedup=true",
"intention_dedup_threshold=0.9",
"--yes",
]);
expect(on.code).toBe(0);
expect(transport.put).toHaveBeenLastCalledWith(
"/api/v1/settings/memory-features",
{ intention_dedup: true, intention_dedup_threshold: 0.9 },
undefined,
);
expect(on.stdout).toContain(
"已更新:intention_dedup、intention_dedup_threshold",
);
expect(on.stdout).toContain("intention_dedup_threshold:0.9(已设置");
transport.get.mockResolvedValue({
status: 200,
data: featuresState({ intention_dedup_threshold: 0.9 }),
});
transport.put.mockResolvedValueOnce({
status: 200,
data: featuresState({}, "pro", {
changed: ["intention_dedup_threshold"],
}),
});
const reset = await capturedRun([
"settings",
"features",
"reset",
"intention_dedup_threshold",
]);
expect(reset.code).toBe(0);
expect(transport.put).toHaveBeenLastCalledWith(
"/api/v1/settings/memory-features",
{ intention_dedup_threshold: null },
undefined,
);
});
it("previews a threshold without --yes and refuses one out of range", async () => {
const preview = await capturedRun([
"settings",
"features",
"set",
"intention_dedup_threshold=0.85",
]);
expect(preview.code).toBe(0);
expect(preview.stdout).toContain("- intention_dedup_threshold = 0.85");
for (const value of ["0.5", "0.79", "1.01", "2", "-0.9", "abc", "1e0"]) {
const result = await capturedRun([
"settings",
"features",
"set",
`intention_dedup_threshold=${value}`,
"--yes",
]);
expect(result.code, value).toBe(2);
}
expect(transport.put).not.toHaveBeenCalled();
});
it("does not send a threshold to a Runtime that does not list it", async () => {
const { parameters: _unused, ...older } = featuresState();
transport.get.mockResolvedValue({ status: 200, data: older });
const result = await capturedRun([
"settings",
"features",
"set",
"intention_dedup_threshold=0.9",
"--yes",
"--json",
]);
expect(result.code).toBe(1);
expect(JSON.parse(result.stdout)).toMatchObject({
state: "rejected",
code: "memory_feature_unsupported",
});
expect(transport.put).not.toHaveBeenCalled();
});
it("does nothing when the values are already set", async () => {
transport.get.mockResolvedValue({
status: 200,
@@ -210,6 +368,8 @@ describe("memhub settings features", () => {
["settings", "features", "set", "graph_rerank=yes"],
["settings", "features", "set", "graph_rerank"],
["settings", "features", "set", "few_shot=true", "few_shot=false"],
["settings", "features", "set", "intention_dedup=0.9"],
["settings", "features", "set", "intention_dedup_threshold=true"],
["settings", "features", "reset", "nope"],
["settings", "features", "toggle"],
]) {
@@ -269,7 +429,11 @@ describe("status pipeline features", () => {
last_evolve_at: null,
graph_build: { state: "idle", users: {} },
evolve: { mode: "pro", schedule: null, running: [], users: {} },
features: { episode_recall: true, intention: false },
features: {
episode_recall: true,
intention: false,
intention_dedup_threshold: 0.9,
},
},
};
const report = await composeStatusReport({
@@ -284,9 +448,12 @@ describe("status pipeline features", () => {
expect(report.pipeline?.features).toEqual({
episode_recall: true,
intention: false,
intention_dedup_threshold: 0.9,
});
const text = renderStatusText(report, "MemHub");
expect(text).toContain("SDK 功能:episode_recall=开、intention=关");
expect(text).toContain(
"SDK 功能:episode_recall=开、intention=关、intention_dedup_threshold=0.9",
);
});
});
+118 -28
View File
@@ -2,7 +2,8 @@
* `memhub settings features [set <name>=<true|false> ... | reset [<name> ...]]`.
*
* The SDK's optional retrieval and extraction features (graph rerank, episode
* recall, the episode write side, intention, few-shot). One global setting in
* recall, the episode write side, intention and its dedup, few-shot), plus the
* intention dedup threshold, the one numeric setting. One global setting in
* the Runtime; an unset feature keeps the SDK default, so an install nobody
* touched behaves as before. `set` prints its plan first and needs `--yes`,
* like `settings mode`, because some features spend model calls; `reset`
@@ -20,10 +21,19 @@ export const MEMORY_FEATURES = [
"session_episodes",
"episode_summary",
"intention",
"intention_dedup",
"few_shot",
] as const;
export type MemoryFeature = (typeof MEMORY_FEATURES)[number];
/** Numeric settings, with the range the Runtime accepts (inclusive). */
export const MEMORY_PARAMETERS = {
intention_dedup_threshold: { minimum: 0.8, maximum: 1 },
} as const;
export type MemoryParameter = keyof typeof MEMORY_PARAMETERS;
export type MemorySetting = MemoryFeature | MemoryParameter;
type SettingValues = Partial<Record<MemorySetting, boolean | number>>;
const FEATURE_LABEL: Record<MemoryFeature, string> = {
graph_rerank: "图谱重排(沿知识图谱的边传播支持度;仅 ultra)",
same_series_timeline:
@@ -33,6 +43,8 @@ const FEATURE_LABEL: Record<MemoryFeature, string> = {
session_episodes: "片段写入(同一会话提取的记忆归入同一片段)",
episode_summary: "片段摘要(片段结束时总结一次;每个片段一次模型调用)",
intention: "意图提取(随事实一起提取用户的计划与意图)",
intention_dedup:
"意图判重(检索时把同一天、相似度达到阈值的意图视为重复,只返回最早一条并删除其余;关闭则不判重、不删除)",
few_shot: "提取示例(在提取提示里附上示例;不加调用,输入更长)",
};
@@ -41,21 +53,42 @@ const REASON_TEXT: Record<string, string> = {
requires_pro: "lite 模式下不生效(需要 pro 或 ultra)",
requires_ultra: "当前模式下不生效(需要 ultra)",
requires_graph_rerank: "需要先开启并生效 graph_rerank",
requires_intention_dedup: "intention_dedup 已关闭,阈值不起作用",
};
const PARAMETER_LABEL: Record<MemoryParameter, string> = {
intention_dedup_threshold: "意图判重阈值(余弦相似度,越高保留越多)",
};
export type FeaturesCommand =
| { kind: "show" }
| {
kind: "set";
values: Partial<Record<MemoryFeature, boolean>>;
values: SettingValues;
confirmed: boolean;
}
| { kind: "reset"; names: MemoryFeature[] };
| { kind: "reset"; names: MemorySetting[] };
function isFeature(value: string): value is MemoryFeature {
return (MEMORY_FEATURES as readonly string[]).includes(value);
}
function isParameter(value: string): value is MemoryParameter {
return Object.hasOwn(MEMORY_PARAMETERS, value);
}
function isSetting(value: string): value is MemorySetting {
return isFeature(value) || isParameter(value);
}
/** A parameter's value: a plain decimal in its range; null = usage. */
function parameterValue(name: MemoryParameter, raw: string): number | null {
if (!/^\d+(\.\d+)?$/.test(raw)) return null;
const value = Number(raw);
const { minimum, maximum } = MEMORY_PARAMETERS[name];
return value >= minimum && value <= maximum ? value : null;
}
/** Arguments after `settings features`; null = usage. */
export function parseFeaturesCommand(
values: readonly string[],
@@ -68,18 +101,28 @@ export function parseFeaturesCommand(
const positional = tail.filter((value) => value !== "--yes");
if (verb === "set") {
if (positional.length === 0) return null;
const parsed: Partial<Record<MemoryFeature, boolean>> = {};
const parsed: SettingValues = {};
for (const item of positional) {
const match = /^([a-z_]+)=(true|false)$/.exec(item);
if (!match || !isFeature(match[1]) || match[1] in parsed) return null;
parsed[match[1]] = match[2] === "true";
const match = /^([a-z_]+)=(.+)$/.exec(item);
if (!match || match[1] in parsed) return null;
const [, name, raw] = match;
if (isFeature(name)) {
if (raw !== "true" && raw !== "false") return null;
parsed[name] = raw === "true";
} else if (isParameter(name)) {
const value = parameterValue(name, raw);
if (value === null) return null;
parsed[name] = value;
} else {
return null;
}
}
return { kind: "set", values: parsed, confirmed: yes === 1 };
}
if (verb === "reset") {
if (new Set(positional).size !== positional.length) return null;
if (!positional.every(isFeature)) return null;
return { kind: "reset", names: positional as MemoryFeature[] };
if (!positional.every(isSetting)) return null;
return { kind: "reset", names: positional as MemorySetting[] };
}
return null;
}
@@ -96,11 +139,26 @@ export interface FeatureState {
description: string;
}
export interface ParameterState {
name: MemoryParameter;
value: number;
configured: number | null;
default: number;
minimum: number;
maximum: number;
active: boolean;
reason: string | null;
requires_feature: string;
description: string;
}
export interface FeaturesState {
mode: string;
features: FeatureState[];
non_default: Partial<Record<MemoryFeature, boolean>>;
changed?: MemoryFeature[];
/** Absent from a Runtime older than the intention dedup threshold. */
parameters?: ParameterState[];
non_default: SettingValues;
changed?: MemorySetting[];
}
function featuresState(value: unknown): FeaturesState | null {
@@ -125,6 +183,20 @@ function featuresState(value: unknown): FeaturesState | null {
return null;
}
}
if (item.parameters !== undefined) {
if (!Array.isArray(item.parameters)) return null;
for (const parameter of item.parameters) {
if (
!parameter ||
typeof parameter !== "object" ||
typeof (parameter as ParameterState).name !== "string" ||
typeof (parameter as ParameterState).value !== "number" ||
typeof (parameter as ParameterState).active !== "boolean"
) {
return null;
}
}
}
return item as unknown as FeaturesState;
}
@@ -137,12 +209,12 @@ export type FeaturesDocument = {
| {
state: "preview";
features: FeaturesState;
target: Partial<Record<MemoryFeature, boolean>>;
target: SettingValues;
applied: false;
next_step: string;
}
| { state: "unchanged"; features: FeaturesState }
| { state: "committed"; features: FeaturesState; changed: MemoryFeature[] }
| { state: "committed"; features: FeaturesState; changed: MemorySetting[] }
| { state: "rejected" | "unknown" | "unavailable"; code: string }
);
@@ -170,13 +242,23 @@ export async function runFeaturesCommand(
if (command.kind === "show") {
return { ...BASE, state: "ok", features: current };
}
// What a person set, per name this Runtime lists; null = the default.
const configured = new Map<string, boolean | number | null>([
...current.features.map(
(feature) => [feature.name, feature.configured] as const,
),
...(current.parameters ?? []).map(
(parameter) => [parameter.name, parameter.configured] as const,
),
]);
let body: Record<string, unknown>;
if (command.kind === "set") {
const configured = new Map(
current.features.map((feature) => [feature.name, feature.configured]),
);
if (Object.keys(command.values).some((name) => !configured.has(name))) {
// An older Runtime does not know the name; it would answer 422.
return { ...BASE, state: "rejected", code: "memory_feature_unsupported" };
}
const differs = Object.entries(command.values).some(
([name, value]) => configured.get(name as MemoryFeature) !== value,
([name, value]) => configured.get(name) !== value,
);
if (!differs) return { ...BASE, state: "unchanged", features: current };
if (!command.confirmed) {
@@ -197,16 +279,10 @@ export async function runFeaturesCommand(
const names =
command.names.length > 0
? command.names
: current.features
.filter((feature) => feature.configured !== null)
.map((feature) => feature.name);
if (
names.every(
(name) =>
current.features.find((feature) => feature.name === name)
?.configured === null,
)
) {
: [...configured]
.filter(([, value]) => value !== null)
.map(([name]) => name as MemorySetting);
if (names.every((name) => (configured.get(name) ?? null) === null)) {
return { ...BASE, state: "unchanged", features: current };
}
body = Object.fromEntries(names.map((name) => [name, null]));
@@ -233,6 +309,16 @@ export function featuresExitCode(document: FeaturesDocument): number {
: 1;
}
function parameterLine(parameter: ParameterState): string {
const label = PARAMETER_LABEL[parameter.name] ?? parameter.name;
const origin = parameter.configured === null ? "默认" : "已设置";
const activity =
!parameter.active && parameter.reason
? `,${REASON_TEXT[parameter.reason] ?? parameter.reason}`
: "";
return `${parameter.name}:${parameter.value}(${origin}${activity},可设 ${parameter.minimum}–${parameter.maximum})— ${label}`;
}
function featureLine(feature: FeatureState): string {
const label = FEATURE_LABEL[feature.name] ?? feature.name;
const value = feature.enabled ? "开" : "关";
@@ -251,6 +337,8 @@ export function renderFeaturesText(document: FeaturesDocument): string {
const state = document.features;
const lines = [`当前模式:${state.mode}`];
for (const feature of state.features) lines.push(featureLine(feature));
for (const parameter of state.parameters ?? [])
lines.push(parameterLine(parameter));
lines.push(
"片段召回(episode_recall)只对带片段的记忆有用:先开启 session_episodes,之后提取的记忆才会带片段。",
);
@@ -258,7 +346,9 @@ export function renderFeaturesText(document: FeaturesDocument): string {
if (document.state === "preview") {
lines.push("将设置:");
for (const [name, value] of Object.entries(document.target)) {
lines.push(`- ${name} = ${value ? "开" : "关"}`);
lines.push(
`- ${name} = ${typeof value === "number" ? value : value ? "开" : "关"}`,
);
}
lines.push(`未执行任何改动。确认后运行:${document.next_step}`);
}
+10
View File
@@ -248,6 +248,10 @@ function addReceipt(receipt: MemoryAddReceipt): Record<string, unknown> {
task_id: receipt.task_id,
memory_ids: receipt.memory_ids,
error: receipt.error,
error_code:
typeof receipt.error_code === "string" ? receipt.error_code : null,
next_retry_at:
typeof receipt.next_retry_at === "string" ? receipt.next_retry_at : null,
};
}
@@ -347,6 +351,12 @@ export function renderMemoryAddText(document: MemoryAddDocument): string {
...((document.receipt.memory_ids as string[]).length > 0
? [`memory_ids: ${(document.receipt.memory_ids as string[]).join(",")}`]
: []),
...(document.receipt.error_code
? [`error_code: ${document.receipt.error_code}`]
: []),
...(document.receipt.next_retry_at
? [`next_retry_at: ${document.receipt.next_retry_at}`]
: []),
document.command === "memories add"
? `查询进度:memhub memories add-status ${document.request_id};重试请带上同一个 --request-id,不会重复添加。`
: "",
+70
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import type { CoreReader, ReadOutcome } from "./core-client.js";
import { composeDoctorReport, renderDoctorText } from "./doctor-report.js";
import {
composeStatusReport,
@@ -41,6 +42,75 @@ describe("status default output", () => {
});
});
describe("status on a paused model", () => {
const processing = (backoff: {
llm_backoff_until: string | null;
llm_backoff_error: string | null;
}) => ({
pending: 4,
fresh_pending: 4,
backlog_pending: 0,
outside_window_pending: 0,
running: 0,
waiting_for_model: 0,
completed: 1,
failed: 0,
blocked_stages: [],
suspended: 2,
...backoff,
});
const reader =
(summary: unknown): CoreReader =>
async <T>(path: string) =>
(path === "/api/v1/memory-processing"
? { state: "ok", data: summary }
: { state: "unavailable", code: "x" }) as ReadOutcome<T>;
const compose = (summary: unknown) =>
composeStatusReport({
dataDir: "/tmp/isolated/.one-memory",
runtime: {
state: "running",
code: "runtime_verified",
host: "127.0.0.1",
port: 30001,
},
background: null,
reader: reader(summary),
});
it("names the code and the retry time while the breaker pauses", async () => {
const report = await compose(
processing({
llm_backoff_until: "2026-11-02T10:00:00+00:00",
llm_backoff_error: "model_auth_failed",
}),
);
expect(report.sections.processing).toMatchObject({
state: "ok",
pending: 4,
suspended: 2,
llm_backoff_until: "2026-11-02T10:00:00+00:00",
llm_backoff_error: "model_auth_failed",
});
const text = renderStatusText(report, "MemHub");
expect(text).toContain(
"模型暂停:model_auth_failed,将于 2026-11-02T10:00:00+00:00 自动重试",
);
expect(text).not.toContain("llm_backoff");
});
it("prints no pause line and null fields when nothing is paused", async () => {
const report = await compose(
processing({ llm_backoff_until: null, llm_backoff_error: null }),
);
expect(report.sections.processing).toMatchObject({
llm_backoff_until: null,
llm_backoff_error: null,
});
expect(renderStatusText(report, "MemHub")).not.toContain("模型暂停");
});
});
describe("status and doctor on an unfinished upgrade migration", () => {
it("names the migration log and the issues page", async () => {
const report = await composeStatusReport({
+55 -8
View File
@@ -85,7 +85,7 @@ export interface PipelineSummary {
>;
graph_build: string | null;
/** SDK features (`memory_features`) whose value differs from the default. */
features: Record<string, boolean>;
features: Record<string, boolean | number>;
/** The App's display language; null = never chosen or "follow the system". */
ui_language: string | null;
/** The SDK prompt language: `auto` (zh/en detection) or the code passed. */
@@ -134,12 +134,13 @@ export function describePipeline(value: unknown): PipelineSummary | null {
};
}
}
const features: Record<string, boolean> = {};
const features: Record<string, boolean | number> = {};
if (item.features && typeof item.features === "object") {
for (const [name, value] of Object.entries(
item.features as Record<string, unknown>,
)) {
if (typeof value === "boolean") features[name] = value;
if (typeof value === "boolean" || typeof value === "number")
features[name] = value;
}
}
const graph =
@@ -319,6 +320,8 @@ export function renderStatusText(
report.sections.processing?.embedding_switch,
);
if (embeddingSwitch !== null) lines.push(`向量模型:${embeddingSwitch}`);
const backoff = modelBackoffLine(report.sections.processing);
if (backoff !== null) lines.push(backoff);
if (report.summary.state === "partial") {
const notes = [
report.summary.degraded.length > 0
@@ -334,6 +337,21 @@ export function renderStatusText(
return lines.join("\n");
}
/** "Model paused" while Core's circuit breaker holds model work back; null
* otherwise. Nothing to do here: the queue resumes by itself once the model
* answers again (or after the model is fixed). */
function modelBackoffLine(section: Section | undefined): string | null {
if (section?.state !== "ok") return null;
const error = section.llm_backoff_error;
if (typeof error !== "string" || error.length === 0) return null;
const until = section.llm_backoff_until;
return `模型暂停:${error},${
typeof until === "string" && until.length > 0
? `将于 ${until} 自动重试`
: "稍后自动重试"
}`;
}
export function pipelineLine(pipeline: PipelineSummary): string {
const parts: string[] = [pipeline.mode];
if (pipeline.forced_by_env)
@@ -357,7 +375,10 @@ export function pipelineLine(pipeline: PipelineSummary): string {
if (changed.length > 0) {
parts.push(
`;SDK 功能:${changed
.map(([name, value]) => `${name}=${value ? "开" : "关"}`)
.map(
([name, value]) =>
`${name}=${typeof value === "number" ? value : value ? "开" : "关"}`,
)
.join("、")}`,
);
}
@@ -380,15 +401,22 @@ function runtimeLine(report: StatusDocument): string {
})`;
}
/** Fields that have their own line (or none) instead of a place in the
* section's one-line summary. */
const SECTION_LINE_OMITTED = new Set([
"state",
"code",
"embedding_switch",
"llm_backoff_until",
"llm_backoff_error",
]);
function sectionLine(section: Section | undefined): string {
if (!section) return "未检查";
if (section.state === "not_checked") return "未检查";
if (section.state !== "ok") return "暂时不可用,请稍后重试";
return Object.entries(section)
.filter(
([key]) =>
key !== "state" && key !== "code" && key !== "embedding_switch",
)
.filter(([key]) => !SECTION_LINE_OMITTED.has(key))
.map(([key, value]) => `${fieldLabel(key)} ${formatValue(value)}`)
.join(" ");
}
@@ -424,6 +452,7 @@ function fieldLabel(key: string): string {
backlog_pending: "历史待处理",
outside_window_pending: "稍后处理",
waiting_for_model: "等待模型",
suspended: "来源已暂停",
completed: "已完成",
failed: "失败",
blocked: "卡住",
@@ -504,10 +533,28 @@ function describeProcessing(
reason: block.reason,
count: block.count,
})),
suspended: typeof summary.suspended === "number" ? summary.suspended : 0,
...llmBackoffFields(summary),
...embeddingSwitchField(summary),
};
}
/** The model circuit breaker: set only while Core pauses model work because
* the provider keeps failing. Both null when nothing is paused. */
function llmBackoffFields(summary: MemoryProcessingSummary): {
llm_backoff_until: string | null;
llm_backoff_error: string | null;
} {
const until = summary.llm_backoff_until;
const error = summary.llm_backoff_error;
return {
llm_backoff_until:
typeof until === "string" && until.length <= 64 ? until : null,
llm_backoff_error:
typeof error === "string" && /^[a-z_]{1,64}$/.test(error) ? error : null,
};
}
/** The running switch or merge, projected; absent when none runs. */
function embeddingSwitchField(
summary: MemoryProcessingSummary,
+21
View File
@@ -208,6 +208,27 @@ describe("--user-id on the memory commands", () => {
expect(direct.get.mock.calls[0][1].params).toEqual({ user_id: "wife" });
});
it("prints the retry code and time of a write still waiting", async () => {
direct.get.mockResolvedValue({
data: {
request_id: REQUEST,
state: "accepted",
created_at: "T1",
updated_at: "T2",
error_code: "model_timeout",
next_retry_at: "2026-11-02T10:00:00+00:00",
},
});
const text = await capturedRun(["write", "status", REQUEST]);
expect(text.stdout).toContain("error_code: model_timeout");
expect(text.stdout).toContain("next_retry_at: 2026-11-02T10:00:00+00:00");
const json = await capturedRun(["write", "status", REQUEST, "--json"]);
expect(JSON.parse(json.stdout).write).toMatchObject({
error_code: "model_timeout",
next_retry_at: "2026-11-02T10:00:00+00:00",
});
});
it("passes the user in the body of memory writes and searches", async () => {
await capturedRun(["memories", "add", "Prefers tea", "--user-id", "wife"]);
expect(transport.post.mock.calls[0][0]).toBe("/api/v2/memories");
+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.11",
"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",
+237 -111
View File
@@ -46,7 +46,12 @@ from pydantic import (
from one_memory import __version__, memory_visibility
from one_memory.auth import local_api_guard, renderer_origins
from one_memory.background_work import shutdown_bulk_pool, warm_bulk_pool
from one_memory.background_work import (
in_sdk_read,
in_the_background,
shutdown_bulk_pool,
warm_bulk_pool,
)
from one_memory.config import Settings, load_settings
from one_memory.context_routes import sdk_context_router
from one_memory.conversation_processing import (
@@ -55,6 +60,7 @@ from one_memory.conversation_processing import (
)
from one_memory.database_access import (
MAIN_DATABASE_REPORT_SECONDS,
checkpoint_idle_main_databases,
close_main_databases,
log_main_database_stats,
main_database,
@@ -204,12 +210,17 @@ from one_memory.memory_evolution import (
)
from one_memory.memory_features import (
FEATURES,
PARAMETERS,
SETTING_NAMES,
FeatureCost,
FeatureOverrides,
FeatureReason,
FeatureSide,
MemoryFeatureName,
MemoryFeatures,
MemoryParameterName,
MemorySettingName,
SettingValue,
effective_features,
feature_activity,
non_default_features,
@@ -228,10 +239,10 @@ from one_memory.memory_server.config_state import (
MemoryMode,
)
from one_memory.memory_tasks import (
NON_RETRYABLE_POST_WRITE_CODES,
MemoryTaskStatus,
MemoryTaskStore,
)
from one_memory.model_backoff import BackedOffModelTransport, ModelBackoff
from one_memory.model_presets import seed_preset_profile
from one_memory.model_probe import ModelProber, ModelProbeResult
from one_memory.model_profiles import (
@@ -285,7 +296,7 @@ from one_memory.sdk_search_options import (
SearchLane,
split_csv,
)
from one_memory.sdk_write_processor import SdkWriteCoordinator
from one_memory.sdk_write_processor import SdkWriteCoordinator, sdk_write_in_flight
from one_memory.secrets import (
KeyringSecretStore,
SecretStore,
@@ -707,6 +718,17 @@ class MemoryProcessingSummary(BaseModel):
#: The running embedding switch or merge, `{job_id, kind, state, percent,
#: eta_s}`; None when none runs. Model writes wait while it is set.
embedding_switch: dict[str, object] | None = None
#: Jobs held back because their source is off or its grant was revoked.
#: Not in `pending` or the queue numbers: they run again, unchanged, once
#: the source is turned back on. Nothing is cancelled or deleted.
suspended: int = 0
#: Until when model work is paused because the provider kept failing
#: (bad key, wrong URL, model missing, provider down); None when it is
#: not. Ends early on success or when the model configuration changes.
llm_backoff_until: datetime | None = None
#: The last provider failure behind the pause, as a stable code
#: (`model_auth_failed`, `model_not_found`, `model_unreachable`, ...).
llm_backoff_error: str | None = None
class MemoryModeState(BaseModel):
@@ -718,6 +740,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
@@ -787,8 +811,9 @@ class PipelineStatus(BaseModel):
last_evolve_at: datetime | None = None
graph_build: GraphBuildState
#: The SDK features (`memory_features`) whose value differs from the SDK
#: default; empty while every feature is at its default.
features: dict[MemoryFeatureName, bool] = Field(default_factory=dict)
#: default; empty while every feature is at its default. A parameter
#: (`intention_dedup_threshold`) appears as its number.
features: dict[MemorySettingName, SettingValue] = Field(default_factory=dict)
#: The display language the App last reported (`ui_language`); null =
#: none set (the App sends the language it shows, even following the system).
ui_language: UiLanguage | None = None
@@ -848,6 +873,30 @@ class MemoryFeatureState(BaseModel):
description: str
class MemoryParameterState(BaseModel):
"""One numeric setting in `GET /api/v1/settings/memory-features`."""
model_config = ConfigDict(extra="forbid")
name: MemoryParameterName
#: What clients are built with: `configured`, else `default`.
value: float
#: What a person set; null = not set, the SDK default applies.
configured: float | None
#: The SDK default (its code default, or its own env var when set).
default: float
#: The range a `PUT` accepts, inclusive.
minimum: float
maximum: float
#: Whether it acts: exactly when `requires_feature` acts.
active: bool
reason: FeatureReason | None
requires_feature: MemoryFeatureName
sdk_field: str
env: str
description: str
class MemoryFeaturesState(BaseModel):
"""`GET /api/v1/settings/memory-features`."""
@@ -855,15 +904,16 @@ class MemoryFeaturesState(BaseModel):
mode: MemoryMode
features: tuple[MemoryFeatureState, ...]
parameters: tuple[MemoryParameterState, ...] = ()
#: Same as `pipeline.features`: the values that differ from the defaults.
non_default: dict[MemoryFeatureName, bool]
non_default: dict[MemorySettingName, SettingValue]
class MemoryFeaturesChange(MemoryFeaturesState):
"""`PUT /api/v1/settings/memory-features`: the new state and what changed."""
#: Features whose value changed; empty = nothing rebuilt.
changed: tuple[MemoryFeatureName, ...] = ()
#: Features and parameters whose value changed; empty = nothing rebuilt.
changed: tuple[MemorySettingName, ...] = ()
def memory_mode_effects(
@@ -897,15 +947,6 @@ def memory_mode_effects(
MemoryProcessingSummary.model_rebuild()
class RetryUnknownMemoryTasksRequest(BaseModel):
confirm: Literal[True]
class RetryUnknownMemoryTasksReceipt(BaseModel):
reconciled: int = 0
requeued: int = 0
class GraphWidthStatus(BaseModel):
"""What the app needs to offer the choice before extraction fails on it."""
@@ -1173,7 +1214,6 @@ def create_app(
)
from one_memory.memory_server.data_safety import MIN_FREE_BYTES
from one_memory.memory_server.durable_queue import (
COMPLETED,
DurableWriteDrain,
DurableWriteQueue,
execute_job,
@@ -1332,6 +1372,9 @@ def create_app(
)
app.state.model_request_budget = model_request_budget
app.state.model_request_audit_store = ModelRequestAuditStore(database_path)
#: One pause for every model request while the provider keeps
#: failing (`model_backoff`); reset when the configuration changes.
app.state.model_backoff = ModelBackoff()
# Before anything asks for an active profile: a first launch that
# carries a preset should show it already filled in, so the user
# meets one empty field instead of a form they cannot answer.
@@ -1350,17 +1393,23 @@ def create_app(
profiles=app.state.model_profile_store,
secrets=app.state.secret_store,
data_dir=runtime_settings.data_dir,
# Outermost, so a request the pause refuses never went out and
# leaves no audit row behind.
transport_factory=lambda profile: (
lambda: AuditedModelTransport(
app.state.model_request_audit_store,
profile_id=profile.profile_id,
profile_revision=profile.revision,
provider_kind=str(profile.provider_kind),
model=profile.model,
inner=(
BudgetedModelTransport(model_request_budget)
if model_request_budget is not None
else None
lambda: BackedOffModelTransport(
app.state.model_backoff,
profile_id=f"{profile.profile_id}:{profile.revision}",
inner=AuditedModelTransport(
app.state.model_request_audit_store,
profile_id=profile.profile_id,
profile_revision=profile.revision,
provider_kind=str(profile.provider_kind),
model=profile.model,
inner=(
BudgetedModelTransport(model_request_budget)
if model_request_budget is not None
else None
),
),
)
),
@@ -1379,6 +1428,9 @@ def create_app(
data_dir=runtime_settings.data_dir,
memory_mode=lambda: _memory_mode(app),
writes_open=lambda: _embedding_writes_open(app),
# The claim gate's own source question, so what is counted as
# waiting is what the drain would claim (#245).
source_active=lambda source_key: _source_writes_on(app, source_key),
)
app.state.evolve_state_store = EvolveStateStore(runtime_settings.data_dir)
app.state.evolve_service = EvolveService(
@@ -1390,10 +1442,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
@@ -1403,24 +1460,15 @@ def create_app(
drain_budget = production_drain_budget()
async def reconcile_unknown_writes() -> None:
unknown = await asyncio.to_thread(
memory_write_queue.unknown_jobs,
limit=128,
exclude_error_codes=NON_RETRYABLE_POST_WRITE_CODES,
# A write whose worker never learned how it ended (a restart,
# a lapsed lease, the work ceiling) goes back into the queue
# under a new SDK request id; the re-run first asks the SDK
# whether the old one landed (`execute_job`). Nothing stays
# `unknown` waiting for a person.
await in_the_background(
memory_write_queue.recover_unknown,
in_flight=sdk_write_in_flight,
)
for job in unknown:
receipt = await app.state.sdk_write_coordinator.inquire_write_outcome(
job.request_id
)
if receipt is None:
continue
await asyncio.to_thread(
memory_write_queue.settle,
job.job_id,
state=COMPLETED,
sdk_raw_ids=receipt.raw_ids,
sdk_memory_ids=receipt.memory_ids,
)
async def admit_hy_invocation(
source_id: str,
@@ -1450,6 +1498,7 @@ def create_app(
evidence=app.state.evidence_store,
source_writes_permitted=lambda source_id: _source_writes_on(app, source_id),
admit_hy_invocation=admit_hy_invocation,
model_failures=app.state.model_backoff.attempt,
),
execute_owned=lambda job_id, owner: execute_job(
job_id,
@@ -1459,6 +1508,7 @@ def create_app(
owner=owner,
source_writes_permitted=lambda source_id: _source_writes_on(app, source_id),
admit_hy_invocation=admit_hy_invocation,
model_failures=app.state.model_backoff.attempt,
),
drain_handle_budget=drain_budget.handle_budget,
drain_cleanup_reserve=drain_budget.cleanup_reserve,
@@ -1486,6 +1536,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,
)
app.state.memory_write_drain = memory_write_drain
@@ -1523,6 +1574,7 @@ def create_app(
app.state.memory_task_store.rebind_unstarted_profile, profile
)
await asyncio.to_thread(memory_write_queue.readmit_model_profile_waiters)
_reset_model_backoff(app)
_resume_sedimentation(app)
_start_backfill(app)
@@ -1797,6 +1849,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()
@@ -1885,6 +1947,7 @@ 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:
await step("evolve_schedule", start_evolve_schedule)
@@ -1998,6 +2061,10 @@ def create_app(
# Last: what has just been shut down was using it.
shutdown_bulk_pool()
log_main_database_stats("shutdown")
# Leave an empty WAL behind: everything that wrote is stopped
# by now. Busy (another process reading) is fine; the next
# start's idle pass truncates it.
await asyncio.to_thread(checkpoint_idle_main_databases)
close_main_databases()
diagnostic_event(
level="info",
@@ -3508,7 +3575,7 @@ def create_app(
)
def create_model_profile(request: ModelProfileInput) -> ModelProfile:
try:
return _model_profile_store(app).create(request, _secret_store(app))
created = _model_profile_store(app).create(request, _secret_store(app))
except EmbeddingBaseUrlRequiredError:
raise HTTPException(422, "embedding_base_url_required") from None
except ModelCreationOperationConflictError:
@@ -3520,6 +3587,8 @@ def create_app(
status_code=503,
detail=_secret_store_detail(error),
) from None
_reset_model_backoff(app)
return created
@app.get("/api/v1/model-profile-creations/{operation_id}", dependencies=protected)
def model_creation_operation(operation_id: str) -> ModelCreationOperationReceipt:
@@ -3554,7 +3623,7 @@ def create_app(
) -> ModelProfile:
_refuse_embedding_edit_during_switch(app, profile_id, request)
try:
return _model_profile_store(app).update(
updated = _model_profile_store(app).update(
profile_id,
request,
_secret_store(app),
@@ -3570,6 +3639,10 @@ def create_app(
status_code=503,
detail=_secret_store_detail(error),
) from None
# A saved key, URL or model is what the paused provider was waiting
# for: try it now, not after the rest of the pause.
_reset_model_backoff(app)
return updated
@app.post(
"/api/v1/model-profiles/{profile_id}/activate",
@@ -3632,6 +3705,7 @@ def create_app(
# D75: work queued for the previous model continues with this one.
# Only tasks that never reached the model are re-bound.
await asyncio.to_thread(app.state.memory_task_store.rebind_unstarted_profile, profile)
_reset_model_backoff(app)
# A missing profile is an event-driven wait, not a provider outage.
# Release those durable rows only now that activation has changed the
# condition; the ordinary drain poll will claim them without a restart.
@@ -4473,7 +4547,10 @@ def create_app(
http_request: Request,
) -> RecallReceipt:
from one_memory.call_event_recorder import record_legacy_result
from one_memory.recall_fallback import recall_with_fallback
from one_memory.recall_fallback import (
recall_total_timeout_seconds,
recall_with_fallback,
)
started = time.monotonic()
await _require_external_recall_scope(
@@ -4489,21 +4566,36 @@ def create_app(
)
raw_options = {"filters": options} if options is not None else {}
access = external_access(http_request.headers)
receipt = await recall_with_fallback(
request,
model_ready=lambda: _recall_model_ready(app),
recall_memories=target.recall_async,
has_memories=lambda: _library_has_memories(app, request.user_id),
raw_sources=lambda: _recall_raw_sources(app, request, access, started),
search_raw=lambda query, limit, sources: _search_sdk_raw(
app,
query,
limit=limit,
sources=sources,
user_id=request.user_id,
**raw_options,
),
)
try:
# The whole answer, not only the search: the checks before and
# after it used to wait for a thread with no limit at all, so a
# pool held by stuck writes left recall hanging while /health
# still answered (#231). Late is a clear 504, not silence.
receipt = await asyncio.wait_for(
recall_with_fallback(
request,
model_ready=lambda: _recall_model_ready(app),
recall_memories=target.recall_async,
has_memories=lambda: _library_has_memories(app, request.user_id),
raw_sources=lambda: _recall_raw_sources(app, request, access, started),
search_raw=lambda query, limit, sources: _search_sdk_raw(
app,
query,
limit=limit,
sources=sources,
user_id=request.user_id,
**raw_options,
),
),
timeout=recall_total_timeout_seconds(request.timeout_ms),
)
except TimeoutError:
logger.warning(
"recall_timed_out timeout_ms=%d elapsed_ms=%.0f",
request.timeout_ms,
(time.monotonic() - started) * 1000,
)
raise HTTPException(status_code=504, detail="recall_timeout") from None
event_store = getattr(app.state, "call_event_store", None)
if event_store is not None and request.query != "__om_probe__":
try:
@@ -4541,6 +4633,8 @@ def create_app(
# running" is how a stalled queue looks busy, so the strand is given
# its own number.
running_rows = counts.get(MemoryTaskStatus.RUNNING, 0)
backoff = cast(ModelBackoff | None, getattr(app.state, "model_backoff", None))
backoff_state = backoff.state() if backoff is not None else None
return MemoryProcessingSummary(
pending=(
sdk_counts.get("pending", 0)
@@ -4581,6 +4675,13 @@ def create_app(
metrics=ProcessingMetrics.model_validate(store.processing_metrics()),
pipeline=_pipeline_status(app),
embedding_switch=_embedding_switch_summary(app),
suspended=sdk_counts.get("suspended", 0),
llm_backoff_until=backoff_state.until if backoff_state is not None else None,
llm_backoff_error=(
backoff_state.last_error
if backoff_state is not None and backoff_state.until is not None
else None
),
)
@app.get(
@@ -4610,13 +4711,19 @@ 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.
"""
_refuse_pipeline_change_during_embedding_maintenance(app)
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:
_refuse_pipeline_change_during_embedding_maintenance(app)
previous_effective = _memory_mode(app)
if request.mode != previous_effective and (
_evolve_service(app).running() or app.state.memory_graph_backfill.running
):
raise HTTPException(status_code=409, detail="memory_lifecycle_busy")
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
@@ -4659,7 +4766,8 @@ def create_app(
"""Turn SDK features on or off; one global setting, not per user.
A key sets that feature, null resets it to the SDK default, an absent
key is left as it is; an unknown key or a non-boolean is 422. A
key is left as it is; an unknown key, a non-boolean feature or an
`intention_dedup_threshold` outside its range is 422. A
change rebuilds the SDK clients like a mode change: a write in flight
finishes with the old features, the next job and search use the new
ones. Nothing stored is migrated and no collection is renamed. See
@@ -4675,7 +4783,7 @@ def create_app(
current = _memory_features(app)
before = effective_features(previous, defaults)
after = effective_features(current, defaults)
changed = tuple(spec.name for spec in FEATURES if before[spec.name] != after[spec.name])
changed = tuple(name for name in SETTING_NAMES if before[name] != after[name])
if current != previous:
# Even a reset to the default value rebuilds: the client then
# carries no override at all, byte for byte the default config.
@@ -4778,14 +4886,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",
@@ -4821,29 +4933,6 @@ def create_app(
raise HTTPException(status_code=404, detail="evolve_run_not_found")
return receipt
@app.post(
"/api/v1/memory-processing/retry-unknown",
response_model=RetryUnknownMemoryTasksReceipt,
dependencies=protected,
)
async def retry_unknown_memory_tasks(
request: RetryUnknownMemoryTasksRequest,
) -> RetryUnknownMemoryTasksReceipt:
"""Retry unknown writes only behind the user's explicit confirmation."""
del request
reconciled = await _sdk_memory_runtime(app).reconcile_unknown_outputs()
# The startup queue knows the authority. A fresh legacy-mode queue
# would recreate the job-evidence table the SDK cutover dropped.
queue = app.state.memory_write_queue
unknown = queue.unknown_jobs(limit=2048)
if app.state.memory_authority is MemoryAuthority.LEGACY:
for job in unknown:
queue.resolve_unknown_from_linked_output(job.job_id)
requeued = sum(queue.retry_unknown_confirmed(job.job_id) for job in unknown)
if app.state.memory_authority is MemoryAuthority.LEGACY:
requeued += app.state.memory_task_store.retry_unknown_confirmed()
return RetryUnknownMemoryTasksReceipt(reconciled=reconciled, requeued=requeued)
@app.get(
"/api/v1/graph-width",
response_model=GraphWidthStatus,
@@ -5010,7 +5099,7 @@ async def _require_external_recall_scope(
# 299 rows from a covering index, about 52ms warm — enough, repeated and
# serialised on the loop, to answer nobody for seconds.
scope_started = time.monotonic()
known = await asyncio.to_thread(_evidence_store(app).known_workspaces, user_id="local")
known = await in_sdk_read(_evidence_store(app).known_workspaces, user_id="local")
# Measured because nothing else records it. The ~52ms figure is from a copy
# of one database at one point in time; this says what it costs here, on a
# database that has since grown, often enough to show whether the read is
@@ -5946,7 +6035,7 @@ async def _recall_model_ready(app: FastAPI) -> bool:
if _memory_mode(app) == "lite":
return False
try:
profile = await asyncio.to_thread(_model_profile_store(app).active, _secret_store(app))
profile = await in_sdk_read(_model_profile_store(app).active, _secret_store(app))
except Exception:
return False
return profile is not None and bool(profile.capabilities.model_available)
@@ -5985,7 +6074,7 @@ async def _library_has_memories(app: FastAPI, user_id: str = DEFAULT_USER_ID) ->
"""Any active memory at all. Asked only after an empty memory search."""
try:
client = await _sdk_client_handle(app).client()
raw = await asyncio.to_thread(
raw = await in_sdk_read(
client.list_memories,
user_id=user_id,
statuses=["active"],
@@ -6025,7 +6114,7 @@ async def _recall_raw_sources(
timeout_ms=request.timeout_ms,
)
try:
authority = await asyncio.to_thread(
authority = await in_sdk_read(
resolve_context_authority,
query,
access,
@@ -6065,7 +6154,7 @@ async def _search_sdk_raw(
if not include:
return ()
client = await _sdk_client_handle(app).client()
raw = await asyncio.to_thread(
raw = await in_sdk_read(
client.search,
query,
**{
@@ -6117,9 +6206,11 @@ 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(),
)
@@ -6160,7 +6251,7 @@ def _ui_language_state(app: FastAPI) -> UiLanguageState:
def _memory_features_state(
app: FastAPI, defaults: dict[str, bool] | None = None
app: FastAPI, defaults: dict[str, SettingValue] | None = None
) -> MemoryFeaturesState:
defaults = sdk_feature_defaults() if defaults is None else defaults
overrides = _memory_features(app)
@@ -6173,9 +6264,9 @@ def _memory_features_state(
features.append(
MemoryFeatureState(
name=spec.name,
enabled=effective[spec.name],
configured=configured.get(spec.name),
default=defaults[spec.name],
enabled=bool(effective[spec.name]),
configured=cast(bool | None, configured.get(spec.name)),
default=bool(defaults[spec.name]),
active=reason is None,
reason=reason,
side=spec.side,
@@ -6187,10 +6278,32 @@ def _memory_features_state(
description=spec.description,
)
)
parameters = []
for parameter in PARAMETERS:
reason = feature_activity(parameter.name, effective, mode)
parameters.append(
MemoryParameterState(
name=parameter.name,
value=float(effective[parameter.name]),
configured=cast(float | None, configured.get(parameter.name)),
default=float(defaults[parameter.name]),
minimum=parameter.minimum,
maximum=parameter.maximum,
active=reason is None,
reason=reason,
requires_feature=parameter.requires_feature,
sdk_field=parameter.sdk_field,
env=parameter.env,
description=parameter.description,
)
)
return MemoryFeaturesState(
mode=mode,
features=tuple(features),
non_default=cast(dict[MemoryFeatureName, bool], non_default_features(overrides, defaults)),
parameters=tuple(parameters),
non_default=cast(
dict[MemorySettingName, SettingValue], non_default_features(overrides, defaults)
),
)
@@ -6211,7 +6324,7 @@ def _pipeline_status(app: FastAPI) -> PipelineStatus | None:
last_evolve_at=max(finished, default=None),
graph_build=state.graph_build,
features=cast(
dict[MemoryFeatureName, bool],
dict[MemorySettingName, SettingValue],
non_default_features(_memory_features(app), sdk_feature_defaults()),
),
**_ui_language_state(app).model_dump(exclude={"languages"}),
@@ -6254,7 +6367,7 @@ async def _get_sdk_memory(
variant: str | None = None,
) -> HySdkMemoryRecord | None:
client = await handle.client()
raw = await asyncio.to_thread(client.get, memory_id, **_namespace_kwargs(kind, variant))
raw = await in_sdk_read(client.get, memory_id, **_namespace_kwargs(kind, variant))
return None if raw is None else HySdkMemoryRecord.from_sdk(raw)
@@ -6416,7 +6529,7 @@ async def _search_sdk_memories(
) -> tuple[object, ...]:
client = await handle.client()
overrides = options.search_overrides() if options is not None else {}
raw = await asyncio.to_thread(
raw = await in_sdk_read(
client.search,
query,
**{
@@ -6492,6 +6605,13 @@ def _user_evolving(app: FastAPI, user_id: str) -> bool:
return bool(callable(evolving) and evolving(user_id))
def _reset_model_backoff(app: FastAPI) -> None:
"""The user changed the model configuration: end any provider pause now."""
backoff = getattr(app.state, "model_backoff", None)
if isinstance(backoff, ModelBackoff):
backoff.reset()
def _embedding_switch(app: FastAPI) -> EmbeddingSwitchService:
return cast(EmbeddingSwitchService, app.state.embedding_switch)
@@ -6522,6 +6642,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"}
)
@@ -15,6 +15,11 @@ BULK_WORKERS = 3
SNAPSHOT_WORKERS = 8
INTERACTIVE_READ_WORKERS = 4
SOURCE_READER_WORKERS = 4
# One per source the drain runs side by side (`max_concurrent_sources`), and
# two over for the outcome inquiries that follow them.
SDK_WRITE_WORKERS = 12
# Recall's SDK reads: one search and the `get`s behind it (`_SDK_GET_CONCURRENCY`).
SDK_READ_WORKERS = 8
_pool: ThreadPoolExecutor | None = None
_snapshot_pool: ThreadPoolExecutor | None = None
@@ -22,6 +27,7 @@ _interactive_pool: ThreadPoolExecutor | None = None
_raw_read_pool: ThreadPoolExecutor | None = None
_memory_read_pool: ThreadPoolExecutor | None = None
_source_reader_pool: ThreadPoolExecutor | None = None
_sdk_write_pool: ThreadPoolExecutor | None = None
_warmed_readers: weakref.WeakSet[ThreadPoolExecutor] = weakref.WeakSet()
_reader_warm_lock = threading.Lock()
_interactive_reads = 0
@@ -71,15 +77,38 @@ def raw_read_pool() -> ThreadPoolExecutor:
def memory_read_pool() -> ThreadPoolExecutor:
"""Recall's own threads: SDK searches, gets, and what decides between them.
Recall used to hand these to the default pool, which SDK writes share. A
write that times out keeps its thread until the SDK returns (#231), so a
few stuck writes left recall waiting for a thread while `/health`, which
needs none, still answered.
"""
global _memory_read_pool
if _memory_read_pool is None:
_memory_read_pool = ThreadPoolExecutor(
max_workers=INTERACTIVE_READ_WORKERS,
max_workers=SDK_READ_WORKERS,
thread_name_prefix="one-memory-memory-read",
)
return _memory_read_pool
def sdk_write_pool() -> ThreadPoolExecutor:
"""SDK writes, bounded and apart from everything else.
Each thread here only waits for the SDK's own loop thread to answer
(`_LoopThread.run`), so any thread outside that loop may make the call; a
stuck write therefore costs a thread of this pool and nobody else's.
"""
global _sdk_write_pool
if _sdk_write_pool is None:
_sdk_write_pool = ThreadPoolExecutor(
max_workers=SDK_WRITE_WORKERS,
thread_name_prefix="one-memory-sdk-write",
)
return _sdk_write_pool
def source_reader_pool() -> ThreadPoolExecutor:
"""Bounded process-wide pool for source iteration and parsing."""
global _source_reader_pool
@@ -175,6 +204,30 @@ async def in_interactive_read[T](
return await in_read_pool(interactive_read_pool(), work, *args, **kwargs)
async def in_sdk_read[T](
work: Callable[..., T],
/,
*args: object,
**kwargs: object,
) -> T:
"""A recall-path read, in recall's own pool (`memory_read_pool`)."""
return await in_read_pool(memory_read_pool(), work, *args, **kwargs)
async def in_sdk_write[T](
work: Callable[..., T],
/,
*args: object,
**kwargs: object,
) -> T:
"""One SDK write call, in the write pool (`sdk_write_pool`)."""
return await asyncio.get_running_loop().run_in_executor(
sdk_write_pool(),
copy_context().run,
partial(work, *args, **kwargs),
)
def _retrieve[T](future: "asyncio.Future[T]") -> None:
"""Mark a shielded read's outcome as seen.
@@ -261,7 +314,10 @@ def _warm_reader_pool(reader: ThreadPoolExecutor) -> None:
def shutdown_bulk_pool() -> None:
global _pool, _snapshot_pool, _interactive_pool, _raw_read_pool, _memory_read_pool
global _source_reader_pool
global _source_reader_pool, _sdk_write_pool
if _sdk_write_pool is not None:
_sdk_write_pool.shutdown(wait=False, cancel_futures=True)
_sdk_write_pool = None
if _pool is not None:
_pool.shutdown(wait=False, cancel_futures=True)
_pool = None
@@ -111,10 +111,26 @@ MAIN_DATABASE_CHECKPOINT_WRITE_INTERVAL = 64
MAIN_DATABASE_CHECKPOINT_SECONDS = 30.0
# Advisory only: PASSIVE cannot force a frame past a long reader. Crossing this
# threshold is a signal that the WAL is not converging and that read snapshots
# or write volume need investigation.
# threshold with frames still waiting to be copied back is a signal that the
# WAL is not converging and that read snapshots or write volume need
# investigation. A large file whose frames are all checkpointed is not: the
# idle TRUNCATE below gives that space back.
MAIN_DATABASE_WAL_WARN_BYTES = 512 * 1024 * 1024
# What a WAL file is cut back to once a checkpoint has copied every frame and
# the writer starts it over. Without a limit SQLite reuses the file in place
# and never shrinks it, so one import peak left a 639 MB WAL behind for good
# (#245). 64 MiB is about sixteen times what the default autocheckpoint (1000
# pages) lets build up between checkpoints, so ordinary write bursts reuse the
# file without growing and shrinking it each time, and it is still a tenth of
# what that user's disk was left holding.
MAIN_DATABASE_JOURNAL_SIZE_LIMIT_BYTES = 64 * 1024 * 1024
# How long an idle TRUNCATE may wait for the writer or for a reader to leave.
# It runs only when nothing holds the database; anyone arriving in the
# meantime wins, and the checkpoint is tried again on the next pass.
MAIN_DATABASE_IDLE_CHECKPOINT_WAIT_MS = 50
def checkpoint_write_interval() -> int:
raw = os.environ.get(
@@ -276,6 +292,8 @@ class MainDatabase:
self._writes_since_checkpoint = 0
self._last_checkpoint_at = time.monotonic()
self._wal_warned_at = 0.0
#: Learned from the first checkpoint; turns WAL frames into bytes.
self._page_size = 4096
self._reader_started_at: dict[int, float] = {}
@contextmanager
@@ -542,16 +560,59 @@ class MainDatabase:
def _checkpoint_from_maintenance(self, mode: str) -> tuple[int, int, int]:
return self._checkpoint(mode, maintenance=True)
def _checkpoint(self, mode: str, *, maintenance: bool) -> tuple[int, int, int]:
def _checkpoint(
self,
mode: str,
*,
maintenance: bool,
busy_timeout_ms: int = WRITE_LOCK_WAIT_MS,
) -> tuple[int, int, int]:
normalized = mode.strip().upper()
if normalized not in {"PASSIVE", "FULL", "RESTART", "TRUNCATE"}:
raise ValueError("unsupported WAL checkpoint mode")
with self.write(autocommit=True, maintenance=maintenance) as connection:
with self.write(
autocommit=True, maintenance=maintenance, busy_timeout_ms=busy_timeout_ms
) as connection:
row = connection.execute(f"PRAGMA wal_checkpoint({normalized})").fetchone()
page_size = connection.execute("PRAGMA page_size").fetchone()
if row is None or len(row) != 3:
raise sqlite3.DatabaseError("SQLite returned an invalid checkpoint result")
if page_size is not None:
self._page_size = int(page_size[0])
return int(row[0]), int(row[1]), int(row[2])
def idle(self) -> bool:
"""Nobody holds or waits for a connection to this database right now."""
with self._state:
return (
self._writer_owner is None
and not self._reader_holders
and not len(self._write_line)
and not len(self._read_line)
)
def checkpoint_if_idle(self) -> tuple[int, int, int] | None:
"""Give the WAL file back to the disk while nothing is using the database.
PASSIVE copies frames back but leaves the file its size; only TRUNCATE
empties it, and TRUNCATE has to wait for every reader to finish, so it
is attempted only when this process holds no checkout at all, and with
a wait of a few milliseconds. A busy result, or someone arriving in
between, is not a failure: the next idle pass tries again. Returns the
checkpoint result, or None when it was not attempted or not admitted.
"""
if not self.idle() or self._wal_bytes() == 0:
return None
try:
return self._checkpoint(
"TRUNCATE",
maintenance=True,
busy_timeout_ms=MAIN_DATABASE_IDLE_CHECKPOINT_WAIT_MS,
)
except sqlite3.Error:
logger.debug("main_database_idle_checkpoint_skipped file=%s", self.path.name)
return None
def _maybe_checkpoint_after_write(self) -> None:
"""Checkpoint on a write cadence without opening another connection.
@@ -579,17 +640,23 @@ class MainDatabase:
logger.warning("main_database_checkpoint_failed file=%s", self.path.name, exc_info=True)
return
wal_bytes = self._wal_bytes()
if wal_bytes >= MAIN_DATABASE_WAL_WARN_BYTES:
# Frames the checkpoint could not copy back yet: what a reader is
# holding up. The file size alone also counts space already
# checkpointed and only waiting to be reused or truncated, which is
# not the WAL failing to converge.
pending_bytes = max(0, log_frames - checkpointed) * self._page_size
if pending_bytes >= MAIN_DATABASE_WAL_WARN_BYTES:
with self._state:
should_warn = now - self._wal_warned_at >= MAIN_DATABASE_REPORT_SECONDS
if should_warn:
self._wal_warned_at = now
if should_warn:
logger.warning(
"main_database_wal_high file=%s wal_bytes=%d busy=%d "
"main_database_wal_high file=%s wal_bytes=%d pending_bytes=%d busy=%d "
"log_frames=%d checkpointed=%d readers_in_use=%d",
self.path.name,
wal_bytes,
pending_bytes,
busy,
log_frames,
checkpointed,
@@ -784,6 +851,10 @@ class MainDatabase:
try:
if wal:
enable_write_ahead_log(connection, wait_ms=busy_timeout_ms)
# Only the writer restarts the WAL, so only the writer needs the cap.
connection.execute(
f"PRAGMA journal_size_limit = {int(MAIN_DATABASE_JOURNAL_SIZE_LIMIT_BYTES)}"
)
_seal(connection)
if busy_timeout_ms != WRITE_LOCK_WAIT_MS:
# Opened at the caller's wait; every checkout starts at the default.
@@ -1027,6 +1098,18 @@ def main_database_transaction(
yield connection
def checkpoint_idle_main_databases() -> int:
"""TRUNCATE the WAL of every database nobody is using; how many were emptied."""
with _databases_lock:
databases = list(_alive.values())
emptied = 0
for database in databases:
result = database.checkpoint_if_idle()
if result is not None and result[0] == 0:
emptied += 1
return emptied
def close_main_databases() -> None:
"""Close every connection this process holds; used when the Core stops."""
with _databases_lock:
@@ -1050,6 +1133,9 @@ async def report_main_database(seconds: float) -> None:
while True:
await asyncio.sleep(seconds)
log_main_database_stats("periodic")
# The idle signal is this process's own checkouts: none held, none
# waiting. Off the loop, since a checkpoint is disk work however short.
await asyncio.to_thread(checkpoint_idle_main_databases)
def main_database_stats() -> dict[str, float | int]:
@@ -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.11"
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.11 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
@@ -47,8 +47,10 @@ from one_memory.local_embedding import LocalEmbedder
from one_memory.log_file import confine_hy_sdk_logging
from one_memory.memory_features import (
FEATURES,
FEATURES_BY_NAME,
PARAMETERS,
SETTINGS_BY_NAME,
FeatureOverrides,
SettingValue,
feature_overrides,
)
from one_memory.model_profiles import (
@@ -321,21 +323,27 @@ def sdk_relevance_floor() -> float:
return min(1.0, max(0.0, value))
def sdk_feature_defaults() -> dict[str, bool]:
"""Each `memory_features` feature's SDK default, as a fresh config has it.
def sdk_feature_defaults() -> dict[str, SettingValue]:
"""Each `memory_features` setting's SDK default, as a fresh config has it.
That is the SDK's code default unless the process environment sets the
SDK's own variable (`FeatureSpec.env`), exactly what an unset feature
leaves on a client.
"""
config = MemoryConfig()
return {
defaults: dict[str, SettingValue] = {
spec.name: bool(getattr(getattr(config, spec.section), spec.field)) for spec in FEATURES
}
for parameter in PARAMETERS:
defaults[parameter.name] = float(
getattr(getattr(config, parameter.section), parameter.field)
)
return defaults
def apply_sdk_features(
config: MemoryConfig, features: Mapping[str, bool] | Iterable[tuple[str, bool]]
config: MemoryConfig,
features: Mapping[str, SettingValue] | Iterable[tuple[str, SettingValue]],
) -> MemoryConfig:
"""Set each feature a person chose on its `MemoryConfig` field.
@@ -346,7 +354,7 @@ def apply_sdk_features(
"""
values = dict(features.items() if isinstance(features, Mapping) else features)
for name, value in feature_overrides(values):
spec = FEATURES_BY_NAME[name]
spec = SETTINGS_BY_NAME[name]
setattr(getattr(config, spec.section), spec.field, value)
return config
@@ -383,7 +391,7 @@ def build_sdk_config(
*,
api_key: str | None,
data_dir: Path,
features: Mapping[str, bool] | Iterable[tuple[str, bool]] = (),
features: Mapping[str, SettingValue] | Iterable[tuple[str, SettingValue]] = (),
language: str | None = None,
) -> MemoryConfig:
"""Map a model snapshot and its embedding space to an SDK configuration.
@@ -34,6 +34,7 @@ from one_memory.memory_governance import MemoryGovernanceStore, MemoryStatus
from one_memory.memory_server.durable_queue import DurableWriteQueue
from one_memory.memory_server.unified_entry import UnifiedWriteIntake
from one_memory.memory_tasks import MemoryTaskStatus, MemoryTaskStore
from one_memory.model_backoff import next_retry_at
from one_memory.secrets import SecretStoreUnavailableError
from one_memory.user_scope import DEFAULT_USER_ID, USER_ID_PATTERN, UserId
@@ -77,6 +78,12 @@ class MemoryAddReceipt(BaseModel):
task_id: str | None = None
memory_ids: list[str] = Field(default_factory=list)
error: str | None = None
#: Why a note still being worked on last failed (`model_auth_failed`, ...),
#: or why a failed one failed. A provider failure keeps it `pending`.
error_code: str | None = None
#: When a pending note is next tried: its own retry wait, or the model
#: provider's pause while the provider is failing.
next_retry_at: str | None = None
class LegacyMemoryAdditionReader:
@@ -258,6 +265,8 @@ class MemoryAdditionStore:
durable_state = durable.state if durable is not None else "failed"
mapped: MemoryAddState = {
"pending": "pending",
# Re-run automatically within seconds (`recover_unknown`).
"unknown": "pending",
"running": "running",
"waiting_for_model": "waiting_for_model",
"waiting_for_permission": "waiting_for_permission",
@@ -272,6 +281,12 @@ class MemoryAdditionStore:
state=mapped,
memory_ids=(list(durable.sdk_memory_ids) if durable is not None else []),
error=durable.detail if durable is not None else "write_missing",
error_code=(
durable.error_code
if durable is not None and durable.state != "completed"
else None
),
next_retry_at=durable.retry_at if durable is not None else None,
)
if self.processor is None:
raise RuntimeError("legacy_memory_add_processor_unavailable")
@@ -402,10 +417,15 @@ def memory_add_router(dependencies: Sequence[Depends]) -> APIRouter:
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
) -> MemoryAddReceipt:
try:
return store(request).receipt_for_request(request_id, user_id=user_id)
receipt = store(request).receipt_for_request(request_id, user_id=user_id)
except KeyError as error:
raise HTTPException(404, "memory_add_not_found") from error
except ValueError as error:
raise HTTPException(409, str(error)) from error
return receipt.model_copy(
update={
"next_retry_at": next_retry_at(request.app, receipt.state, receipt.next_retry_at)
}
)
return router
@@ -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:
@@ -21,6 +21,11 @@ exercises (query rewrite needs `queries=[...]`, session narratives need
cross-encoder download, access write-back); and everything MemHub already
controls elsewhere (pipeline log, trace, memory operations, telemetry, mode).
Beside the on/off features there is one numeric parameter,
`intention_dedup_threshold` (`PARAMETERS`): the cosine similarity at which the
read-time intention dedup treats two intentions as copies. It is set, stored,
reset and keyed exactly like a feature.
No SDK import here: only `hy_sdk_runtime` and `hy_sdk_migration` import it.
`config_state` imports this module, so it imports nothing from there.
"""
@@ -31,7 +36,7 @@ from collections.abc import Mapping
from dataclasses import dataclass
from typing import Literal
from pydantic import BaseModel, ConfigDict, StrictBool
from pydantic import BaseModel, ConfigDict, Field, StrictBool
MemoryFeatureName = Literal[
"graph_rerank",
@@ -40,8 +45,13 @@ MemoryFeatureName = Literal[
"session_episodes",
"episode_summary",
"intention",
"intention_dedup",
"few_shot",
]
#: Numeric settings beside the on/off features.
MemoryParameterName = Literal["intention_dedup_threshold"]
#: Every key of the setting: a feature or a parameter.
MemorySettingName = MemoryFeatureName | MemoryParameterName
#: Read features change search; write features change what extraction stores.
FeatureSide = Literal["read", "write"]
#: none: no extra call. extra_query: one more store query per search.
@@ -49,9 +59,25 @@ FeatureSide = Literal["read", "write"]
#: a longer extraction prompt.
FeatureCost = Literal["none", "extra_query", "llm_call", "prompt_tokens"]
#: Why an enabled feature does nothing right now (`active: false`).
FeatureReason = Literal["disabled", "requires_pro", "requires_ultra", "requires_graph_rerank"]
FeatureReason = Literal[
"disabled",
"requires_pro",
"requires_ultra",
"requires_graph_rerank",
"requires_intention_dedup",
]
#: A feature is on or off; a parameter is a number.
type SettingValue = bool | float
#: A client's features as a hashable key: sorted (name, value) pairs, set ones only.
type FeatureOverrides = tuple[tuple[str, bool], ...]
type FeatureOverrides = tuple[tuple[str, SettingValue], ...]
#: The range a person may set `intention_dedup_threshold` to. With dedup on,
#: the SDK deletes every intention at or above it from the store at read time,
#: which cannot be undone; below 0.8 the embedding models MemHub uses score
#: different plans on one topic as that close, so a lower value would delete
#: intentions that are not copies. 1.0 keeps only exact copies' dedup.
INTENTION_DEDUP_THRESHOLD_MIN = 0.8
INTENTION_DEDUP_THRESHOLD_MAX = 1.0
_MODE_RANK: dict[str, int] = {"lite": 0, "pro": 1, "ultra": 2}
@@ -169,6 +195,23 @@ FEATURES: tuple[FeatureSpec, ...] = (
"No extra call."
),
),
FeatureSpec(
name="intention_dedup",
section="recall",
field="intention_dedup_enabled",
env="MEMORY_INTENTION_DEDUP_ENABLED",
side="read",
requires_mode="pro",
requires_feature=None,
cost="none",
description=(
"Treat same-day intentions (L7) with the same expiry and a cosine "
"similarity at or above intention_dedup_threshold as copies: a "
"search returns the earliest and deletes the others from the store. "
"On by default; off hides, drops and deletes no intention, at read "
"time or in a write batch. No extra call."
),
),
FeatureSpec(
name="few_shot",
section="llm",
@@ -189,12 +232,57 @@ FEATURE_NAMES: tuple[MemoryFeatureName, ...] = tuple(spec.name for spec in FEATU
FEATURES_BY_NAME: dict[str, FeatureSpec] = {spec.name: spec for spec in FEATURES}
@dataclass(frozen=True)
class ParameterSpec:
"""A numeric setting; it acts only while `requires_feature` acts."""
name: MemoryParameterName
section: Literal["recall"]
field: str
env: str
minimum: float
maximum: float
requires_feature: MemoryFeatureName
description: str
@property
def sdk_field(self) -> str:
return f"{self.section}.{self.field}"
PARAMETERS: tuple[ParameterSpec, ...] = (
ParameterSpec(
name="intention_dedup_threshold",
section="recall",
field="intention_dedup_threshold",
env="MEMORY_INTENTION_DEDUP_THRESHOLD",
minimum=INTENTION_DEDUP_THRESHOLD_MIN,
maximum=INTENTION_DEDUP_THRESHOLD_MAX,
requires_feature="intention_dedup",
description=(
"Cosine similarity at or above which intention_dedup treats two "
"intentions as copies. Higher keeps more. The SDK default is 0.95 "
"(MEMORY_DEDUP_THRESHOLD also sets it when its own env var is unset)."
),
),
)
PARAMETER_NAMES: tuple[MemoryParameterName, ...] = tuple(spec.name for spec in PARAMETERS)
PARAMETERS_BY_NAME: dict[str, ParameterSpec] = {spec.name: spec for spec in PARAMETERS}
#: Every key of the setting, features first, each in its catalog order.
SETTING_NAMES: tuple[MemorySettingName, ...] = (*FEATURE_NAMES, *PARAMETER_NAMES)
SETTINGS_BY_NAME: dict[str, FeatureSpec | ParameterSpec] = {
**FEATURES_BY_NAME,
**PARAMETERS_BY_NAME,
}
class MemoryFeatures(BaseModel):
"""`config.json` `memory_features`, and the `PUT` body.
Every key is optional; absent (or null) means the SDK default. In a `PUT`
an absent key is left as it is and null resets it. Unknown keys and
non-boolean values are refused (422).
an absent key is left as it is and null resets it. Unknown keys,
non-boolean feature values and a threshold that is not a number in its
range are refused (422).
"""
model_config = ConfigDict(frozen=True, extra="forbid", strict=True)
@@ -205,7 +293,14 @@ class MemoryFeatures(BaseModel):
session_episodes: StrictBool | None = None
episode_summary: StrictBool | None = None
intention: StrictBool | None = None
intention_dedup: StrictBool | None = None
few_shot: StrictBool | None = None
intention_dedup_threshold: float | None = Field(
default=None,
ge=INTENTION_DEDUP_THRESHOLD_MIN,
le=INTENTION_DEDUP_THRESHOLD_MAX,
allow_inf_nan=False,
)
def overrides(self) -> FeatureOverrides:
"""The features a person set, as the client key."""
@@ -221,24 +316,47 @@ class MemoryFeatures(BaseModel):
return MemoryFeatures(**{key: value for key, value in values.items() if value is not None})
def feature_overrides(values: Mapping[str, bool]) -> FeatureOverrides:
unknown = set(values) - set(FEATURES_BY_NAME)
def feature_overrides(values: Mapping[str, SettingValue]) -> FeatureOverrides:
unknown = set(values) - set(SETTINGS_BY_NAME)
if unknown:
raise ValueError("unknown_memory_feature")
return tuple(sorted((name, bool(value)) for name, value in values.items()))
return tuple(sorted((name, _setting_value(name, value)) for name, value in values.items()))
def _setting_value(name: str, value: SettingValue) -> SettingValue:
parameter = PARAMETERS_BY_NAME.get(name)
if parameter is None:
return bool(value)
if isinstance(value, bool) or not parameter.minimum <= float(value) <= parameter.maximum:
raise ValueError("invalid_memory_feature_value")
return float(value)
def effective_features(
overrides: FeatureOverrides, defaults: Mapping[str, bool]
) -> dict[str, bool]:
"""Every feature's value: what a person set, else the SDK default."""
values: dict[str, bool] = {spec.name: bool(defaults.get(spec.name, False)) for spec in FEATURES}
overrides: FeatureOverrides, defaults: Mapping[str, SettingValue]
) -> dict[str, SettingValue]:
"""Every setting's value: what a person set, else the SDK default."""
values: dict[str, SettingValue] = {
spec.name: bool(defaults.get(spec.name, False)) for spec in FEATURES
}
for parameter in PARAMETERS:
if parameter.name in defaults:
values[parameter.name] = float(defaults[parameter.name])
values.update(dict(overrides))
return values
def feature_activity(name: str, effective: Mapping[str, bool], mode: str) -> FeatureReason | None:
"""None when the feature acts in this mode; else why it does not."""
def feature_activity(
name: str, effective: Mapping[str, SettingValue], mode: str
) -> FeatureReason | None:
"""None when the feature acts in this mode; else why it does not.
A parameter acts exactly when the feature it tunes acts.
"""
parameter = PARAMETERS_BY_NAME.get(name)
if parameter is not None:
reason = feature_activity(parameter.requires_feature, effective, mode)
return "requires_intention_dedup" if reason == "disabled" else reason
spec = FEATURES_BY_NAME[name]
if not effective.get(name, False):
return "disabled"
@@ -252,11 +370,12 @@ def feature_activity(name: str, effective: Mapping[str, bool], mode: str) -> Fea
def non_default_features(
overrides: FeatureOverrides, defaults: Mapping[str, bool]
) -> dict[str, bool]:
"""The features whose value differs from the SDK default, for status."""
overrides: FeatureOverrides, defaults: Mapping[str, SettingValue]
) -> dict[str, SettingValue]:
"""The settings whose value differs from the SDK default, for status."""
baseline = effective_features((), defaults)
return {
name: value
for name, value in effective_features(overrides, defaults).items()
if value != bool(defaults.get(name, False))
if value != baseline.get(name)
}
@@ -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")
@@ -21,6 +21,7 @@ import shutil
import sqlite3
import time as _time
from collections.abc import Awaitable, Callable
from contextlib import AbstractContextManager, nullcontext
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
@@ -52,6 +53,7 @@ from one_memory.memory_tasks import (
from one_memory.memory_tasks import (
POLICY_STOPPED_CODES as _TASK_POLICY_STOPPED_CODES,
)
from one_memory.model_backoff import ModelAttempt
from one_memory.sdk_migration_log import error_chain_text
from one_memory.service_levels import ServiceLevel, service_level_context
from one_memory.user_scope import DEFAULT_USER_ID, lane_key, other_user_lane_prefix
@@ -107,6 +109,43 @@ PARKED_STATES = (WAITING_FOR_PERMISSION, WAITING_FOR_MODEL)
#: re-admitted on a timer. The gap keeps a still-down model from spinning.
DEFAULT_MODEL_READMIT_AFTER = timedelta(minutes=1)
#: How long a job the execute gate handed back (`release_unstarted`) is left
#: alone before it can be claimed again.
RELEASE_HOLD = timedelta(seconds=5)
#: Automatic retries of a job whose attempt failed for a reason other than the
#: model provider: content the model or the SDK refused, a parse error, a
#: crash or an interruption mid-write. After the last one the job is `failed`,
#: shown with its code, and the queue moves on. A provider failure (a broken
#: key, a model that is gone, a provider that is down) never counts: the job
#: waits in `pending` for as long as the provider is broken, paced by
#: `model_backoff`.
JOB_RETRY_LIMIT = 3
JOB_RETRY_FIRST_BACKOFF = timedelta(seconds=30)
JOB_RETRY_BACKOFF_CAP = timedelta(minutes=3)
#: The code a job interrupted mid-write carries when it has no better one.
INTERRUPTED_ERROR_CODE = "durable_write_interrupted"
def job_retry_backoff(retry: int) -> timedelta:
"""The wait before automatic retry number `retry` (1-based)."""
wait: timedelta = JOB_RETRY_FIRST_BACKOFF * (2 ** min(max(retry - 1, 0), 16))
return min(wait, JOB_RETRY_BACKOFF_CAP)
def sdk_request_id(idempotency_key: str, attempt: int) -> str:
"""The SDK request id of a job's `attempt`-th write.
The SDK keeps one outcome per request id and replays it: a write that
failed under an id fails under it forever, without calling the model.
Every re-run therefore gets an id of its own. Attempt 0 is the job's own
key, so every job written before this existed keeps the id its outcome
was recorded under.
"""
return idempotency_key if attempt <= 0 else f"{idempotency_key}#r{attempt}"
#: Production drain-budget defaults. These are the single source consumed by
#: the composition root; callers must not invent a second formula or copy a
#: test probe's numbers into the product path.
@@ -337,6 +376,10 @@ LEGACY_MEMORY_WRITE_JOB_COLUMNS = (
("time_quality", "TEXT NOT NULL DEFAULT 'approximate'"),
("sdk_raw_ids_json", "TEXT NOT NULL DEFAULT '[]'"),
("sdk_memory_ids_json", "TEXT NOT NULL DEFAULT '[]'"),
#: Which SDK request id the next write uses (`sdk_request_id`).
("sdk_attempt", "INTEGER NOT NULL DEFAULT 0"),
#: Automatic retries spent (`JOB_RETRY_LIMIT`); provider failures spend none.
("retries", "INTEGER NOT NULL DEFAULT 0"),
)
_FIFO_BLOCKING_STATES = (PENDING, RUNNING, WAITING_FOR_PERMISSION, WAITING_FOR_MODEL)
@@ -381,6 +424,10 @@ class DurableWriteRecord:
detail: str = ""
sdk_raw_ids: tuple[str, ...] = ()
sdk_memory_ids: tuple[str, ...] = ()
#: The stable code of the last failure, if the row carries one.
error_code: str | None = None
#: When a waiting row may next be claimed, if a retry wait holds it.
retry_at: str | None = None
@dataclass(frozen=True)
@@ -746,10 +793,10 @@ class DurableWriteQueue:
"""Recover expired leases on the only engine D56 permits.
A lease whose owner died before HY was invoked is safe to retry. Once
`hy_invoked` is recorded, the outcome may already exist in HY and its
requests have no idempotency key, so that row becomes `unknown`
instead of `pending`. Unknown is intentionally sticky: a later reclaim
pass cannot turn it back into retryable work.
`hy_invoked` is recorded the write may already have landed, so that
row becomes `unknown` instead of `pending`. `unknown` is only a stop
on the way: `recover_unknown` re-runs it under a new SDK request id,
after asking the SDK whether the old one succeeded.
"""
now = datetime.now(UTC).isoformat()
changed = 0
@@ -1509,13 +1556,15 @@ class DurableWriteQueue:
with main_database(self.database_path).read(row_factory=sqlite3.Row) as connection:
_require_queue_schema(connection)
row = connection.execute(
"SELECT job_id, state, created_at, updated_at, last_error, "
"SELECT job_id, state, created_at, updated_at, last_error, lease_expires_at, "
"sdk_raw_ids_json,sdk_memory_ids_json "
"FROM memory_write_jobs WHERE idempotency_key=?",
(request_id,),
).fetchone()
if row is None:
return None
last_error = str(row["last_error"] or "")
retry_at = row["lease_expires_at"]
return DurableWriteRecord(
job_id=str(row["job_id"]),
request_id=request_id,
@@ -1526,6 +1575,14 @@ class DurableWriteQueue:
detail=str(row["last_error"] or ""),
sdk_raw_ids=tuple(json.loads(str(row["sdk_raw_ids_json"]))),
sdk_memory_ids=tuple(json.loads(str(row["sdk_memory_ids_json"]))),
error_code=last_error if _ERROR_CODE.fullmatch(last_error) else None,
retry_at=(
str(retry_at)
if str(row["state"]) == PENDING
and retry_at is not None
and str(retry_at) > datetime.now(UTC).isoformat()
else None
),
)
def payload_for(self, job_id: str) -> WriteRequest | None:
@@ -1726,9 +1783,23 @@ class DurableWriteQueue:
)
return updated.rowcount == 1
def release_unstarted(self, job_id: str, *, owner: str | None = None) -> bool:
"""Return a claimed job to pending when its source was paused pre-HY."""
now = datetime.now(UTC).isoformat()
def release_unstarted(
self,
job_id: str,
*,
owner: str | None = None,
hold: timedelta = RELEASE_HOLD,
) -> bool:
"""Return a claimed job to pending when its source was paused pre-HY.
The row comes back with its lease still set `hold` into the future.
Claim only takes a pending row whose lease has expired, so a job the
execute gate just refused is not claimed again on the very next tick:
when the claim gate and the execute gate disagree for good (a
pre-upgrade payload whose Evidence belongs to another user than its
lane), the job is retried every few seconds instead of in a tight loop.
"""
now = datetime.now(UTC)
where = "job_id=? AND state=? AND hy_invoked=0"
params: tuple[Any, ...] = (job_id, RUNNING)
if owner is not None:
@@ -1738,11 +1809,175 @@ class DurableWriteQueue:
updated = connection.execute(
"UPDATE memory_write_jobs SET state=?,lease_owner=NULL,"
"attempts=CASE WHEN attempts>0 THEN attempts-1 ELSE 0 END,"
"lease_expires_at=NULL,updated_at=? WHERE " + where,
(PENDING, now, *params),
"lease_expires_at=?,updated_at=? WHERE " + where,
(PENDING, (now + hold).isoformat(), now.isoformat(), *params),
)
return updated.rowcount == 1
def sdk_attempt(self, job_id: str) -> int:
"""Which SDK request id this job's next write uses (`sdk_request_id`)."""
with main_database(self.database_path).read() as connection:
row = connection.execute(
"SELECT sdk_attempt FROM memory_write_jobs WHERE job_id=?", (job_id,)
).fetchone()
return int(row[0]) if row is not None else 0
def requeue_for_model(
self,
job_id: str,
*,
error_code: str,
owner: str | None = None,
) -> bool:
"""Put a job the model provider failed back in line, as it was.
The provider is broken, not the job: it keeps its place in the queue
(`event_at`, `queue_seq`), spends no attempt and no retry, and is
claimed again once `model_backoff` lets a probe through. Whatever the
SDK may have kept of the failed write stays under that write's request
id; the next one gets an id of its own (`sdk_attempt`), so the SDK
does not replay the failure instead of calling the model.
"""
now = datetime.now(UTC).isoformat()
where = "job_id=? AND state=?"
params: tuple[Any, ...] = (job_id, RUNNING)
if owner is not None:
where += " AND lease_owner=?"
params += (owner,)
with main_database(self.database_path).write() as connection, connection:
updated = connection.execute(
"UPDATE memory_write_jobs SET state=?,lease_owner=NULL,lease_expires_at=NULL,"
"attempts=CASE WHEN attempts>0 THEN attempts-1 ELSE 0 END,"
"sdk_attempt=sdk_attempt+hy_invoked,hy_invoked=0,"
"last_error=?,updated_at=? WHERE " + where,
(PENDING, error_code, now, *params),
)
return updated.rowcount == 1
def retry_or_fail(
self,
job_id: str,
*,
error_code: str,
owner: str | None = None,
now: datetime | None = None,
) -> str:
"""Retry a job that failed for a reason of its own, or give up on it.
Up to `JOB_RETRY_LIMIT` retries, each after a longer wait
(`job_retry_backoff`), held by the lease so the claim skips the job
until then. After the last one the job is `failed`: shown with its
code, out of the queue's way. A write that reached the SDK is re-run
under a new request id. Returns the state written, or "" when the row
is no longer this owner's.
"""
now = now or datetime.now(UTC)
with (
main_database(self.database_path).write(row_factory=sqlite3.Row) as connection,
connection,
):
row = connection.execute(
"SELECT state,lease_owner,retries FROM memory_write_jobs WHERE job_id=?",
(job_id,),
).fetchone()
if (
row is None
or str(row["state"]) != RUNNING
or (owner is not None and row["lease_owner"] != owner)
):
return ""
retry = int(row["retries"]) + 1
if retry > JOB_RETRY_LIMIT:
connection.execute(
"UPDATE memory_write_jobs SET state=?,lease_owner=NULL,lease_expires_at=NULL,"
"last_error=?,updated_at=? WHERE job_id=? AND state=?",
(FAILED, error_code, now.isoformat(), job_id, RUNNING),
)
return FAILED
connection.execute(
"UPDATE memory_write_jobs SET state=?,lease_owner=NULL,lease_expires_at=?,"
"attempts=CASE WHEN attempts>0 THEN attempts-1 ELSE 0 END,"
"sdk_attempt=sdk_attempt+hy_invoked,hy_invoked=0,retries=?,"
"last_error=?,updated_at=? WHERE job_id=? AND state=?",
(
PENDING,
(now + job_retry_backoff(retry)).isoformat(),
retry,
error_code,
now.isoformat(),
job_id,
RUNNING,
),
)
return PENDING
def recover_unknown(
self,
*,
in_flight: Callable[[str], bool] = lambda _request_id: False,
limit: int = 128,
now: datetime | None = None,
) -> int:
"""Move every `unknown` job back into the queue, or out of it.
A job is `unknown` when its write may have reached the SDK and its
worker never learned how it ended: the process stopped, the lease
lapsed, or the work ceiling gave up on it. It is re-run, counted as a
retry so a crash loop ends in `failed`. The re-run gets a new SDK
request id and, before it starts, asks the SDK how the previous id
ended (`execute_job`): a write that did succeed is completed from its
receipt instead of being made twice.
Jobs from before this existed (upgrade) are handled the same way,
once: from then on they follow the normal rules. A write whose SDK
call is still running in this process (`in_flight`) is left until it
ends. A legacy row whose write is known to have landed against an
authority Core could not verify (`NON_RETRYABLE_POST_WRITE_CODES`) is
never re-run: it is `failed`, which says so.
"""
now = now or datetime.now(UTC)
changed = 0
with (
main_database(self.database_path).write(row_factory=sqlite3.Row) as connection,
connection,
):
rows = connection.execute(
"SELECT job_id,idempotency_key,sdk_attempt,retries,last_error "
"FROM memory_write_jobs WHERE state=? ORDER BY queue_seq LIMIT ?",
(UNKNOWN, limit),
).fetchall()
for row in rows:
if in_flight(sdk_request_id(str(row["idempotency_key"]), int(row["sdk_attempt"]))):
continue
last_error = str(row["last_error"] or "")
code = last_error if _ERROR_CODE.fullmatch(last_error) else INTERRUPTED_ERROR_CODE
retry = int(row["retries"]) + 1
if last_error in NON_RETRYABLE_POST_WRITE_CODES or retry > JOB_RETRY_LIMIT:
changed += connection.execute(
"UPDATE memory_write_jobs SET state=?,lease_owner=NULL,"
"lease_expires_at=NULL,last_error=?,updated_at=? "
"WHERE job_id=? AND state=?",
(FAILED, code, now.isoformat(), str(row["job_id"]), UNKNOWN),
).rowcount
continue
changed += connection.execute(
"UPDATE memory_write_jobs SET state=?,lease_owner=NULL,lease_expires_at=?,"
"attempts=0,sdk_attempt=sdk_attempt+1,hy_invoked=0,retries=?,"
"last_error=?,updated_at=? WHERE job_id=? AND state=?",
(
PENDING,
(now + job_retry_backoff(retry)).isoformat(),
retry,
code,
now.isoformat(),
str(row["job_id"]),
UNKNOWN,
),
).rowcount
if changed:
logger.info("durable_write_unknown_recovered count=%d", changed)
return changed
def settle_worker_error(self, job_id: str, *, error_code: str, owner: str | None = None) -> str:
"""Fallback settlement when the injected executor raises past its guard.
@@ -2008,60 +2243,6 @@ class DurableWriteQueue:
)
return str(target)
def retry_unknown_confirmed(self, job_id: str) -> int:
"""Requeue one unknown write only after an explicit user decision.
Automatic workers may never move ``unknown`` back to pending because
HY may already contain the result. This separate command boundary is
intentionally named for the confirmation it requires. The durable
job and any task rows created from the same Evidence are reset in one
short transaction, so the unified queue remains the only execution
entry and a half-reset attempt cannot become permanently stranded.
"""
now = datetime.now(UTC).isoformat()
with (
main_database(self.database_path).write(row_factory=sqlite3.Row) as connection,
connection,
):
row = connection.execute(
"SELECT state,last_error FROM memory_write_jobs WHERE job_id=?", (job_id,)
).fetchone()
if (
row is None
or str(row["state"]) != UNKNOWN
or str(row["last_error"]) in NON_RETRYABLE_POST_WRITE_CODES
):
return 0
# After the SDK cutover the legacy task tables are gone and the
# job is the only row to reset.
if not self.sdk_authority and _legacy_task_links_present(connection):
connection.execute(
"""
UPDATE memory_tasks
SET status='pending', runner_token=NULL, attempts=0,
last_error_code='model_content_manual_retry_requested', updated_at=?
WHERE status='unknown' AND task_id IN (
SELECT inputs.task_id
FROM memory_task_inputs AS inputs, json_each(inputs.refs_json) AS ref
JOIN memory_write_job_evidence AS linked
ON linked.evidence_id=json_extract(ref.value, '$.evidence_id')
WHERE linked.job_id=?
)
""",
(now, job_id),
)
changed = connection.execute(
"""
UPDATE memory_write_jobs
SET state='pending', attempts=0, lease_owner=NULL,
lease_expires_at=NULL, hy_invoked=0,
last_error='model_content_manual_retry_requested', updated_at=?
WHERE job_id=? AND state='unknown'
""",
(now, job_id),
).rowcount
return changed
def readmit_cancelled_permission_confirmed(self, job_id: str, request: WriteRequest) -> int:
"""Requeue one unsent permission cancellation after explicit consent.
@@ -2114,115 +2295,6 @@ class DurableWriteQueue:
).rowcount
return changed
def retry_timed_out_unknown_after_reconcile(
self,
*,
older_than: datetime,
limit: int = 32,
) -> int:
"""Requeue bounded timeout unknowns only after the caller queried HY.
This is not the general unknown retry path. Only a timed-out write with
attempts remaining is eligible, and attempts are preserved so repeated
timeouts stop at the durable ceiling instead of looping forever.
"""
if type(limit) is not int or limit < 1:
raise ValueError("limit must be positive")
now = datetime.now(UTC).isoformat()
with (
main_database(self.database_path).write(row_factory=sqlite3.Row) as connection,
connection,
):
rows = connection.execute(
"SELECT job_id FROM memory_write_jobs "
"WHERE state=? AND last_error='hy_memory_timeout' "
"AND attempts < max_attempts AND updated_at <= ? "
"ORDER BY updated_at, job_id LIMIT ?",
(UNKNOWN, older_than.isoformat(), limit),
).fetchall()
job_ids = tuple(str(row["job_id"]) for row in rows)
if not job_ids:
return 0
placeholders = ",".join("?" for _ in job_ids)
connection.execute(
f"UPDATE memory_write_jobs SET state=?, lease_owner=NULL, "
f"lease_expires_at=NULL, hy_invoked=0, "
f"last_error='model_content_reconciled_absent_retry', updated_at=? "
f"WHERE state=? AND job_id IN ({placeholders})",
(PENDING, now, UNKNOWN, *job_ids),
)
if _legacy_task_links_present(connection):
connection.execute(
f"UPDATE memory_tasks SET status=?, runner_token=NULL, "
f"last_error_code='model_content_reconciled_absent_retry', updated_at=? "
f"WHERE status=? AND task_id IN ("
f"SELECT inputs.task_id FROM memory_task_inputs AS inputs "
f"JOIN memory_write_job_evidence AS linked ON EXISTS ("
f"SELECT 1 FROM json_each(inputs.refs_json) AS ref "
f"WHERE json_extract(ref.value, '$.evidence_id')=linked.evidence_id) "
f"WHERE linked.job_id IN ({placeholders}))",
(MemoryTaskStatus.PENDING, now, MemoryTaskStatus.UNKNOWN, *job_ids),
)
return len(job_ids)
def retry_first_unknown_after_reconcile(
self,
*,
older_than: datetime,
limit: int = 32,
) -> int:
"""Retry one unresolved unknown outcome once, after reconciliation.
The caller first asks HY and checks linked governed output. Rows still
unknown may have lost the response after the SDK call, so one bounded
retry is useful. `attempts=1` is the guard: the retry claim increments
it to two, and a second unknown is left for explicit user recovery.
"""
if type(limit) is not int or limit < 1:
raise ValueError("limit must be positive")
now = datetime.now(UTC).isoformat()
with (
main_database(self.database_path).write(row_factory=sqlite3.Row) as connection,
connection,
):
rows = connection.execute(
"SELECT job_id FROM memory_write_jobs "
"WHERE state=? AND attempts=1 AND attempts < max_attempts "
f"AND COALESCE(last_error, '') NOT IN "
f"({','.join('?' for _ in NON_RETRYABLE_POST_WRITE_CODES)}) "
"AND updated_at <= ? ORDER BY updated_at, job_id LIMIT ?",
(
UNKNOWN,
*NON_RETRYABLE_POST_WRITE_CODES,
older_than.isoformat(),
limit,
),
).fetchall()
job_ids = tuple(str(row["job_id"]) for row in rows)
if not job_ids:
return 0
placeholders = ",".join("?" for _ in job_ids)
connection.execute(
f"UPDATE memory_write_jobs SET state=?, lease_owner=NULL, "
f"lease_expires_at=NULL, hy_invoked=0, "
f"last_error='model_content_unknown_auto_retry', updated_at=? "
f"WHERE state=? AND attempts=1 AND job_id IN ({placeholders})",
(PENDING, now, UNKNOWN, *job_ids),
)
if _legacy_task_links_present(connection):
connection.execute(
f"UPDATE memory_tasks SET status=?, runner_token=NULL, "
f"last_error_code='model_content_unknown_auto_retry', updated_at=? "
f"WHERE status=? AND task_id IN ("
f"SELECT inputs.task_id FROM memory_task_inputs AS inputs "
f"JOIN memory_write_job_evidence AS linked ON EXISTS ("
f"SELECT 1 FROM json_each(inputs.refs_json) AS ref "
f"WHERE json_extract(ref.value, '$.evidence_id')=linked.evidence_id) "
f"WHERE linked.job_id IN ({placeholders}))",
(MemoryTaskStatus.PENDING, now, MemoryTaskStatus.UNKNOWN, *job_ids),
)
return len(job_ids)
def resolve_unknown_from_linked_output(self, job_id: str) -> bool:
"""Close an unknown durable row when local governance output proves success."""
with main_database(self.database_path).read(row_factory=sqlite3.Row) as connection:
@@ -2361,7 +2433,9 @@ class DurableWriteRegistry:
# distinguishing detail. Parked and cancelled states keep their own
# names — collapsing an authorization stop into `failed` is exactly the
# pollution the task layer already rejects.
if record.state in (PENDING, RUNNING):
if record.state in (PENDING, RUNNING, UNKNOWN):
# `unknown` no longer waits for anyone: the drain re-runs it
# within seconds (`recover_unknown`), so to a caller it is queued.
state = "accepted"
elif record.state == DEAD:
state = "failed"
@@ -2385,6 +2459,8 @@ class DurableWriteRegistry:
detail=detail,
raw_ids=record.sdk_raw_ids,
memory_ids=record.sdk_memory_ids,
error_code=record.error_code if record.state != COMPLETED else None,
next_retry_at=record.retry_at,
)
def submit(
@@ -2466,12 +2542,22 @@ async def execute_job(
source_writes_permitted: Callable[[str], bool] | None = None,
admit_hy_invocation: Callable[[str, Callable[[], Awaitable[bool]]], Awaitable[bool]]
| None = None,
model_failures: Callable[[], AbstractContextManager[ModelAttempt | None]] | None = None,
) -> None:
"""Run one durable job through the SDK-authority processor.
``process_sdk_job`` owns the one public SDK write. ``hy_invoked`` is
recorded immediately before that call, so a crash mid-write is later
reclaimed to ``unknown`` instead of being retried blindly.
reclaimed to ``unknown`` and re-run (`recover_unknown`).
Each write uses the SDK request id of the job's current attempt
(`sdk_request_id`). A re-run first asks the SDK how the previous id
ended, and completes from its receipt if it succeeded.
``model_failures`` (`ModelBackoff.attempt`) says whether the provider
failed while this write was in flight: such a job goes back to `pending`
as it was and waits for the provider. Any other failure is retried
`JOB_RETRY_LIMIT` times and then `failed`.
"""
# Every store call here is synchronous SQLite work on the single writer or
# a reader; made from the loop it would block health checks and the API for
@@ -2547,16 +2633,31 @@ async def execute_job(
raise RuntimeError("durable_write_lease_lost_before_invocation")
hy_invoked = True
attempt = await in_the_background(queue.sdk_attempt, job_id)
window = model_failures() if model_failures is not None else nullcontext(None)
provider: ModelAttempt | None = None
try:
level = ServiceLevel.REALTIME if request.priority == "live" else ServiceLevel.BACKGROUND
with service_level_context(level):
sdk_result = await supervisor.process_sdk_job(
records,
request_id=request.idempotency_key,
before_hy_invocation=mark_hy_invocation,
with service_level_context(level), window as provider:
previous = (
await _previous_write_receipt(
supervisor, sdk_request_id(request.idempotency_key, attempt - 1)
)
if attempt > 0
else None
)
raw_ids = sdk_result.raw_ids
memory_ids = sdk_result.memory_ids
if previous is not None:
# The write before this one did land: its worker only never
# learned so. Complete from its receipt rather than write twice.
raw_ids, memory_ids = previous.raw_ids, previous.memory_ids
else:
sdk_result = await supervisor.process_sdk_job(
records,
request_id=sdk_request_id(request.idempotency_key, attempt),
before_hy_invocation=mark_hy_invocation,
)
raw_ids = sdk_result.raw_ids
memory_ids = sdk_result.memory_ids
except Exception as error: # noqa: BLE001 - classified below
# Never persist the raw message: it may be a provider response or the
# user's own text, and `last_error` is shown over HTTP as `detail`.
@@ -2576,51 +2677,56 @@ async def execute_job(
):
await in_the_background(queue.release_unstarted, job_id, owner=owner)
return
# The write coordinator classifies these before any SDK client is leased, so HY
# was provably not called. They must not read as `failed`:
provider_failure = provider.failure if provider is not None else None
if hy_invoked and provider_failure is not None:
# The provider failed this write (a broken key, a model that is
# gone, a provider that is down): the job is fine and waits for
# the provider, in its place, without spending a retry. The
# breaker paces when it is tried again.
await in_the_background(
queue.requeue_for_model, job_id, error_code=provider_failure, owner=owner
)
logger.info(
"durable_write_job_waiting_for_provider job_id=%s error_code=%s sdk_error=%s",
job_id,
provider_failure,
code,
)
return
# Refusals made before the SDK was called. They prove nothing was
# sent, and are states, not failures:
# - authorization stop -> cancelled (the user turned it off);
# - no permission yet -> waiting_for_permission (parked, re-admitted
# when a snapshot admits it);
# - transient model/provider trouble -> waiting_for_model (parked).
# Everything else may have crossed into HY, and HY has no idempotency
# key, so it is `unknown` rather than a blind retry.
# - no permission yet -> waiting_for_permission (re-admitted when a
# snapshot admits it);
# - no usable model yet -> waiting_for_model (re-admitted when one is).
state: str | None = None
if code in PRE_SEND_REFUSAL_CODES:
# The frozen input window is checked before the payload reaches
# HY. This one refusal therefore has a proven pre-send boundary.
state, invoked_marker = FAILED, False
elif hy_invoked:
# Error names do not prove execution phase: permission and
# evidence rechecks also run after HY returns. Once the durable
# marker is set, every failure is possibly-sent and must stay out
# of the automatic retry path.
state, invoked_marker = UNKNOWN, True
elif code in POLICY_STOPPED_CODES:
state, invoked_marker = CANCELLED, False
elif code in PERMISSION_REQUIRED_CODES or code.startswith("model_content_policy"):
state, invoked_marker = WAITING_FOR_PERMISSION, False
elif code in MODEL_NOT_READY_CODES:
state, invoked_marker = WAITING_FOR_MODEL, False
elif code == "model_content_transport_request_failed":
# The generation or embedding HTTP exchange can fail after HY has
# already persisted a node. Treating that as a pre-send failure
# permits a blind duplicate; the confirmed unknown flow first
# reconciles by request identity and only then requeues absence.
state, invoked_marker = UNKNOWN, True
elif code.startswith("model_content_") and code != "model_content_outcome_unknown":
state, invoked_marker = FAILED, False
# Deterministic: the same input exceeds the same window again.
state = FAILED
elif not hy_invoked and code in POLICY_STOPPED_CODES:
state = CANCELLED
elif not hy_invoked and (
code in PERMISSION_REQUIRED_CODES or code.startswith("model_content_policy")
):
state = WAITING_FOR_PERMISSION
elif not hy_invoked and code in MODEL_NOT_READY_CODES:
state = WAITING_FOR_MODEL
if state is not None:
await in_the_background(
queue.settle,
job_id,
state=state,
error_code=code,
hy_invoked=hy_invoked,
owner=owner,
)
else:
# Force the marker even if this row was not claimed first (a direct
# call): the outcome is only `unknown` because HY may have been
# reached, and reclaim must treat it as possibly-sent.
state, invoked_marker = UNKNOWN, True
await in_the_background(
queue.settle,
job_id,
state=state,
error_code=code,
hy_invoked=invoked_marker,
owner=owner,
)
# Anything else is this job's own trouble: content the model or
# the SDK refused, a reply that did not parse, a check that failed
# after the write. Retried a few times, then `failed`.
state = await in_the_background(
queue.retry_or_fail, job_id, error_code=code, owner=owner
)
# Never `logger.exception` here: it prints the exception's message,
# which may be a provider response or the user's own text. Log the
# already-sanitised code instead, matching `hy-failure-classification`.
@@ -2638,9 +2744,10 @@ async def execute_job(
# chain, never its message: `error_code=AttributeError` alone
# named no location.
logger.error(
"durable_write_job_failed job_id=%s error_code=%s chain=%s",
"durable_write_job_failed job_id=%s error_code=%s state=%s chain=%s",
job_id,
code,
state or "stale",
error_chain_text(error),
)
return
@@ -2665,6 +2772,14 @@ async def execute_job(
)
async def _previous_write_receipt(supervisor: Any, request_id: str) -> Any | None:
"""The receipt of an earlier write of this job, if the SDK says it succeeded."""
inquire = getattr(supervisor, "inquire_write_outcome", None)
if inquire is None:
return None
return await inquire(request_id)
class DurableWriteDrain:
"""Run SQLite durable jobs with lease, periodic renewal and no blind retry.
@@ -2694,6 +2809,7 @@ class DurableWriteDrain:
reconcile_unknown: Callable[[], Awaitable[None]] | None = None,
source_writes_permitted: Callable[[str], bool] | None = None,
source_claim_enabled: Callable[[str], bool] | None = None,
model_claim_allowance: Callable[[], int | None] | None = None,
) -> None:
self.queue = queue
self.execute = execute
@@ -2769,6 +2885,11 @@ class DurableWriteDrain:
self.reconcile_unknown = reconcile_unknown
self.source_writes_permitted = source_writes_permitted
self.source_claim_enabled = source_claim_enabled or source_writes_permitted
#: How many jobs may be claimed now while the model provider is being
#: given a rest (`model_backoff`): None for no limit, 0 to claim none.
#: Without it a backlog failing in 50ms each was hundreds of requests
#: a minute against a provider that refused every one (#245).
self.model_claim_allowance = model_claim_allowance
self._last_unknown_reconcile: datetime | None = None
def claim(
@@ -2812,6 +2933,14 @@ class DurableWriteDrain:
return ()
except sqlite3.Error:
pass
if self.model_claim_allowance is not None:
# Asked only once there is work to claim: while the provider is
# paused nothing is, and after the pause one job probes it.
allowance = self.model_claim_allowance()
if allowance is not None:
if allowance < 1:
return ()
limit = min(limit, allowance)
enabled_sources: set[str] | None = None
if self.source_writes_permitted is not None:
with main_database(self.queue.database_path).read() as connection:
@@ -3163,6 +3163,33 @@ async def _060_memory_note_requests(cursor: Any) -> None:
await cursor.execute(statement)
def write_job_retries_ddl(dialect: str = "mysql") -> str:
"""Per-job retry state: which SDK request id is next, and retries spent.
The SDK replays a request id's first outcome forever, so every re-run of
a job needs an id of its own (`sdk_attempt`). `retries` counts automatic
retries after failures of the job itself; provider failures spend none.
"""
if dialect == "sqlite":
return """
ALTER TABLE memory_write_jobs
ADD COLUMN sdk_attempt INTEGER NOT NULL DEFAULT 0;
ALTER TABLE memory_write_jobs
ADD COLUMN retries INTEGER NOT NULL DEFAULT 0;
"""
return """
ALTER TABLE memory_write_jobs
ADD COLUMN sdk_attempt INT NOT NULL DEFAULT 0;
ALTER TABLE memory_write_jobs
ADD COLUMN retries INT NOT NULL DEFAULT 0;
"""
async def _061_write_job_retries(cursor: Any) -> None:
for statement in _split_statements(write_job_retries_ddl(_dialect(cursor))):
await cursor.execute(statement)
def _split_statements(sql: str) -> list[str]:
"""Split a multi-statement script, dropping blanks, PRAGMA and comments.
@@ -3462,4 +3489,5 @@ MIGRATIONS: tuple[Migration, ...] = (
_060_memory_note_requests,
("memory_note_requests",),
),
Migration(61, "write_job_retries", _061_write_job_retries),
)
@@ -57,6 +57,11 @@ class WriteRecord:
detail: str = ""
raw_ids: tuple[str, ...] = ()
memory_ids: tuple[str, ...] = ()
#: The stable code of the last failure of a job still being worked on
#: (or of a failed one).
error_code: str | None = None
#: When a waiting job is next tried, if a retry wait holds it.
next_retry_at: str | None = None
def _now() -> str:
@@ -44,6 +44,7 @@ from one_memory.memory_server.durable_queue import (
)
from one_memory.memory_server.registry import WriteRegistry, WriteState
from one_memory.memory_server.submit import WriteConflictError
from one_memory.model_backoff import next_retry_at
from one_memory.user_scope import DEFAULT_USER_ID, USER_ID_PATTERN, UserId
#: Durable keys of another user's writes start with this. A `local` request
@@ -72,6 +73,12 @@ class MemoryWriteReceipt(BaseModel):
detail: str = ""
raw_ids: tuple[str, ...] = ()
memory_ids: tuple[str, ...] = ()
#: Why a job still being worked on last failed (`model_auth_failed`, ...),
#: or why a failed one failed. A provider failure keeps `state` queued.
error_code: str | None = None
#: When it is next tried: the job's own retry wait, or the model
#: provider's pause (`model_backoff`) while the provider is failing.
next_retry_at: str | None = None
class OpenAIMessage(BaseModel):
@@ -235,6 +242,8 @@ def sdk_router(dependencies: list[Any] | None = None) -> APIRouter:
detail=record.detail,
raw_ids=record.raw_ids,
memory_ids=record.memory_ids,
error_code=record.error_code,
next_retry_at=next_retry_at(request.app, record.state, record.next_retry_at),
)
return instance
@@ -0,0 +1,328 @@
"""One pause for every model request while the provider keeps failing (#245).
A broken model configuration (a revoked key, a wrong base URL, a model that
does not exist, a provider that is down) used to fail each queued job on its
own: the drain claimed the next job the moment the last one failed, so a
backlog of hundreds of jobs turned into requests as fast as the provider could
refuse them. The pause is global, not per job: a failure says something about
the provider, not about the conversation that happened to hit it.
After the first failure no further model work is claimed for 5 seconds; each
failed probe after that doubles the pause, up to 3 minutes. A pause admits one
probe job at a time. The first success, or any change the user makes to the
model configuration, ends it at once. Jobs not yet sent stay `pending`.
"""
from __future__ import annotations
import logging
import threading
import time
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any
import httpx
logger = logging.getLogger(__name__)
BACKOFF_FIRST_SECONDS = 5.0
BACKOFF_CAP_SECONDS = 180.0
#: How long one probe may hold the pause open before another is admitted. A
#: probe job that never reaches the provider (nothing to remember, a refusal
#: before the SDK) reports nothing, and the queue must not wait on it forever.
PROBE_HOLD_SECONDS = 30.0
#: Requests already in flight when the pause began are refused without going
#: out once this many failures in a row confirm it is not a one-off.
REFUSE_IN_FLIGHT_AFTER = 2
#: Statuses that say the configuration or the provider is wrong, not the one
#: request: authentication, a missing model or route, throttling, a timeout
#: and the provider's own failures. A 400/413/422 is about the request's
#: content and is left to that job.
_PROVIDER_STATUSES = {401: "model_auth_failed", 403: "model_auth_failed", 404: "model_not_found"}
#: Every code a provider failure is recorded under: a job that failed with one
#: of these waits for the provider instead of spending a retry.
MODEL_FAILURE_CODES = frozenset(
{
"model_auth_failed",
"model_not_found",
"model_timeout",
"model_rate_limited",
"model_server_error",
"model_unreachable",
"model_transport_failed",
}
)
def backoff_seconds(level: int) -> float:
"""The pause after the `level`-th failed attempt in a row (1-based)."""
if level < 1:
return 0.0
return min(BACKOFF_FIRST_SECONDS * float(2 ** min(level - 1, 16)), BACKOFF_CAP_SECONDS)
def failure_code(status: int) -> str | None:
"""The stable code for a provider HTTP status, or None if it is no provider failure."""
if status in _PROVIDER_STATUSES:
return _PROVIDER_STATUSES[status]
if status in {408, 504}:
return "model_timeout"
if status == 429:
return "model_rate_limited"
if status >= 500:
return "model_server_error"
return None
def transport_failure_code(error: BaseException) -> str:
if isinstance(error, httpx.TimeoutException):
return "model_timeout"
if isinstance(error, httpx.ConnectError | httpx.NetworkError):
return "model_unreachable"
return "model_transport_failed"
def request_kind(request: httpx.Request) -> str:
"""`embedding` for the embeddings route, `chat` for everything else."""
return "embedding" if request.url.path.rstrip("/").endswith("/embeddings") else "chat"
@dataclass(frozen=True)
class ModelBackoffState:
#: When the pause ends; None when no pause is in force.
until: datetime | None
#: Failed attempts in a row.
failures: int
#: The last failure's stable code (`model_auth_failed`, ...).
last_error: str | None
profile_id: str | None
class ModelBackoffRefusal(httpx.TransportError):
"""A request refused locally because the provider is being given a rest."""
@dataclass(eq=False)
class ModelAttempt:
"""One job's write, as the provider saw it.
`failure` is the code of the first provider failure (or local refusal)
that happened while this write was in flight, None if there was none.
"""
failure: str | None = None
class ModelBackoff:
"""Process-wide circuit breaker for the active model profile.
Thread-safe: the transport records from the SDK's own loop thread, the
drain asks from its claim thread, and status reads from the event loop.
"""
def __init__(
self,
*,
clock: Callable[[], float] = time.monotonic,
wall_clock: Callable[[], datetime] = lambda: datetime.now(UTC),
) -> None:
self._clock = clock
self._wall_clock = wall_clock
self._lock = threading.Lock()
#: Writes in flight (`attempt`); never cleared by a reset.
self._attempts: set[ModelAttempt] = set()
self._reset_locked()
def _reset_locked(self) -> None:
#: Which kinds of request are failing (`chat`, `embedding`): a working
#: embedding endpoint says nothing about a chat model that is gone.
self._failing: set[str] = set()
self._level = 0
self._failures = 0
self._until = 0.0
self._probe_until = 0.0
self._last_error: str | None = None
self._profile_id: str | None = None
def reset(self, reason: str = "model_configuration_changed") -> None:
"""Forget every failure: the user changed what is being called."""
with self._lock:
was_paused = self._failures > 0
self._reset_locked()
if was_paused:
logger.info("model_backoff_reset reason=%s", reason)
def record_success(self, profile_id: str | None = None, kind: str = "model") -> None:
with self._lock:
if self._failures == 0:
return
if profile_id is not None and self._profile_id not in {None, profile_id}:
return
self._failing.discard(kind)
if self._failing:
return
self._reset_locked()
logger.info("model_backoff_cleared profile_id=%s", profile_id)
def record_failure(self, code: str, profile_id: str | None = None, kind: str = "model") -> None:
now = self._clock()
with self._lock:
if profile_id is not None and self._profile_id not in {None, profile_id}:
# Another profile's history says nothing about this one.
self._reset_locked()
self._profile_id = profile_id
self._failing.add(kind)
self._mark_attempts_locked(code)
self._failures += 1
self._last_error = code
self._probe_until = 0.0
# Requests that were already out when the pause began fail too;
# they are the same outage, not a reason to wait longer.
if now >= self._until:
self._level += 1
self._until = now + backoff_seconds(self._level)
level, pause = self._level, self._until - now
logger.warning(
"model_backoff_failure error_code=%s level=%d pause_s=%.0f profile_id=%s",
code,
level,
pause,
profile_id,
)
@contextmanager
def attempt(self) -> Iterator[ModelAttempt]:
"""Hold one job's write open, and learn whether the provider failed it.
Every provider failure and every local refusal while the write is in
flight is charged to it. A request cannot be traced to the job that
made it: the SDK sends embeddings from one shared batching worker, so
neither the calling thread nor a context variable identifies the job.
Charging every write then in flight can name a write the provider did
not fail (another job's request failed beside it); that write waits
for the provider and is retried with the rest. It can never miss the
write the provider did fail, which would spend that job's retries on
an outage.
"""
record = ModelAttempt()
with self._lock:
self._attempts.add(record)
try:
yield record
finally:
with self._lock:
self._attempts.discard(record)
def record_refusal(self) -> str:
"""A request refused locally; charged to the writes in flight like a failure."""
with self._lock:
code = self._last_error or "model_transport_failed"
self._mark_attempts_locked(code)
return code
def _mark_attempts_locked(self, code: str) -> None:
for record in self._attempts:
if record.failure is None:
record.failure = code
def paused(self) -> bool:
with self._lock:
return self._failures > 0 and self._clock() < self._until
def refuses_requests(self, kind: str = "model") -> bool:
"""Whether a request of this kind should not go out at all right now.
Only the failing kind is refused: a chat model that is gone must not
take recall's embedding calls down with it.
"""
with self._lock:
return (
kind in self._failing
and self._failures >= REFUSE_IN_FLIGHT_AFTER
and self._clock() < self._until
)
def allowance(self) -> int | None:
"""How many model jobs may be claimed now: None for no limit.
0 while paused; once the pause is over, one probe at a time until it
reports or `PROBE_HOLD_SECONDS` pass.
"""
now = self._clock()
with self._lock:
if self._failures == 0:
return None
if now < self._until or now < self._probe_until:
return 0
self._probe_until = now + PROBE_HOLD_SECONDS
return 1
def state(self) -> ModelBackoffState:
now = self._clock()
with self._lock:
remaining = self._until - now if self._failures else 0.0
return ModelBackoffState(
until=(
self._wall_clock() + timedelta(seconds=remaining) if remaining > 0 else None
),
failures=self._failures,
last_error=self._last_error,
profile_id=self._profile_id,
)
def next_retry_at(app: Any, state: str, own: str | None) -> str | None:
"""When a queued job is tried next: its own retry wait, or the provider's pause.
While the breaker pauses, nothing is claimed at all, so the pause holds
every queued job, whatever it last failed with.
"""
if state not in {"accepted", "pending"}:
return None
backoff = getattr(getattr(app, "state", None), "model_backoff", None)
until = backoff.state().until if isinstance(backoff, ModelBackoff) else None
paused = until.isoformat() if until is not None else None
candidates = [value for value in (own, paused) if value]
return max(candidates) if candidates else None
class BackedOffModelTransport(httpx.AsyncBaseTransport):
"""Report each provider exchange to the breaker; refuse while it confirms a pause."""
def __init__(
self,
backoff: ModelBackoff,
*,
profile_id: str,
inner: httpx.AsyncBaseTransport | None = None,
) -> None:
self.backoff = backoff
self.profile_id = profile_id
self.inner = inner or httpx.AsyncHTTPTransport(retries=0, trust_env=False)
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
kind = request_kind(request)
if self.backoff.refuses_requests(kind):
self.backoff.record_refusal()
raise ModelBackoffRefusal("model_backoff_active", request=request)
try:
response = await self.inner.handle_async_request(request)
except ModelBackoffRefusal:
raise
except httpx.TransportError as error:
self.backoff.record_failure(transport_failure_code(error), self.profile_id, kind)
raise
code = failure_code(response.status_code)
if code is not None:
self.backoff.record_failure(code, self.profile_id, kind)
elif response.status_code < 400:
self.backoff.record_success(self.profile_id, kind)
return response
async def aclose(self) -> None:
await self.inner.aclose()
@@ -32,6 +32,19 @@ FallbackReason = Literal["model_unavailable", "no_memories"]
#: caller). An empty set means the caller may read no raw conversation.
RawSources = frozenset[str] | None
#: What the whole recall request may take beyond the caller's own
#: `timeout_ms`: the search keeps to that budget, but the checks around it
#: (is a model configured, is the library empty, which raw sources) and the
#: raw fallback need a little room of their own. Well under the broker's 15 s
#: wait on Core, so a slow Core answers 504 before the broker gives up.
RECALL_TOTAL_GRACE_SECONDS = 3.0
RECALL_TOTAL_CEILING_SECONDS = 12.0
def recall_total_timeout_seconds(timeout_ms: int) -> float:
"""The most one recall request may take from start to answer."""
return min(timeout_ms / 1_000 + RECALL_TOTAL_GRACE_SECONDS, RECALL_TOTAL_CEILING_SECONDS)
async def recall_with_fallback(
request: RecallRequest,
@@ -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 COMPLETED, DurableWriteQueue
from one_memory.memory_server.durable_queue import DurableWriteQueue
from one_memory.sdk_write_processor import SdkWriteCoordinator
from one_memory.semantic_deadline import SemanticMemoryHit
@@ -34,6 +34,10 @@ _STABLE_CODE = re.compile(r"[a-z][a-z0-9_]{0,95}")
logger = logging.getLogger(__name__)
#: Jobs not yet run that a suspended source holds back.
_WAITING_STATES = frozenset({"pending", "waiting_for_model", "waiting_for_permission"})
_WAITING_SQL = ",".join(f"'{state}'" for state in sorted(_WAITING_STATES))
class SdkMemoryRuntime:
"""Product services that remain after legacy task/governance writes retire."""
@@ -48,12 +52,19 @@ class SdkMemoryRuntime:
data_dir: Path,
memory_mode: Callable[[], str] = lambda: "pro",
writes_open: Callable[[], bool] = lambda: True,
source_active: Callable[[str], bool] = lambda _source_key: True,
) -> None:
self.coordinator = coordinator
#: The SDK mode, for the graph store's path (`hy_graph_admin`).
self.memory_mode = memory_mode
#: False while an embedding switch pauses model writes.
self.writes_open = writes_open
#: Whether a queue lane's source may still become memories: the same
#: question the drain's claim asks of it. A lane that is off (the
#: user turned the source off, or its grant was revoked) has its
#: waiting jobs suspended: kept, never claimed, and not counted as
#: pending until the source is on again.
self.source_active = source_active
self.read_handle = read_handle
self.write_queue = write_queue
self.extraction_settings = extraction_settings
@@ -241,22 +252,11 @@ class SdkMemoryRuntime:
now = datetime.now(UTC)
fresh_since = (now - timedelta(days=7)).isoformat()
with main_database(self.write_queue.database_path).read() as database:
fresh = int(
database.execute(
"SELECT COUNT(*) FROM memory_write_jobs "
"WHERE state IN ('pending','waiting_for_model','waiting_for_permission') "
"AND event_at>=?",
(fresh_since,),
).fetchone()[0]
)
backlog = int(
database.execute(
"SELECT COUNT(*) FROM memory_write_jobs "
"WHERE state IN ('pending','waiting_for_model','waiting_for_permission') "
"AND event_at<?",
(fresh_since,),
).fetchone()[0]
)
rows = database.execute(
"SELECT source_key,event_at>=? AS fresh,COUNT(*) FROM memory_write_jobs "
f"WHERE state IN ({_WAITING_SQL}) GROUP BY source_key,fresh",
(fresh_since,),
).fetchall()
recent = tuple(
datetime.fromisoformat(str(row[0]))
for row in database.execute(
@@ -264,6 +264,9 @@ class SdkMemoryRuntime:
"ORDER BY updated_at DESC LIMIT 8"
)
)
active = self._active_lanes({str(row[0]) for row in rows})
fresh = sum(int(row[2]) for row in rows if str(row[0]) in active and row[1])
backlog = sum(int(row[2]) for row in rows if str(row[0]) in active and not row[1])
return QueueDepths(fresh=fresh, backlog=backlog, recent_completions=recent)
def active_depths(self) -> tuple[int, int]:
@@ -276,13 +279,27 @@ class SdkMemoryRuntime:
return running, running
def status_counts(self) -> dict[str, int]:
"""Jobs per state, with a suspended source's waiting jobs as `suspended`.
Those jobs keep their state in the table: suspension is the source's
switch, not the row's, so turning the source back on is all it takes
for the drain to claim them again, and nothing is moved or deleted.
"""
with main_database(self.write_queue.database_path).read() as database:
return {
str(row[0]): int(row[1])
for row in database.execute(
"SELECT state,COUNT(*) FROM memory_write_jobs GROUP BY state"
)
}
rows = database.execute(
"SELECT source_key,state,COUNT(*) FROM memory_write_jobs GROUP BY source_key,state"
).fetchall()
active = self._active_lanes({str(row[0]) for row in rows if str(row[1]) in _WAITING_STATES})
counts: dict[str, int] = {}
for source_key, state, count in rows:
name = str(state)
if name in _WAITING_STATES and str(source_key) not in active:
name = "suspended"
counts[name] = counts.get(name, 0) + int(count)
return counts
def _active_lanes(self, lanes: set[str]) -> set[str]:
return {lane for lane in lanes if self.source_active(lane)}
def manual_batch_progress(self) -> tuple[int, int]:
return 0, 0
@@ -291,31 +308,6 @@ class SdkMemoryRuntime:
del task_ids
return []
async def reconcile_unknown_outputs(
self,
*,
limit: int = 128,
exclude_error_codes: tuple[str, ...] = (),
) -> int:
reconciled = 0
for job in await asyncio.to_thread(
self.write_queue.unknown_jobs,
limit=limit,
exclude_error_codes=exclude_error_codes,
):
receipt = await self.coordinator.inquire_write_outcome(job.request_id)
if receipt is None:
continue
await asyncio.to_thread(
self.write_queue.settle,
job.job_id,
state=COMPLETED,
sdk_raw_ids=receipt.raw_ids,
sdk_memory_ids=receipt.memory_ids,
)
reconciled += 1
return reconciled
async def resume_allowed(self) -> None:
await asyncio.to_thread(self.write_queue.readmit_parked)
@@ -29,6 +29,7 @@ from collections.abc import Callable
from pathlib import Path
from typing import Any
from one_memory.background_work import in_sdk_read
from one_memory.embedding_health import EmbeddingGuardedClient, EmbeddingHealth
from one_memory.embedding_spaces import (
EmbeddingSpace,
@@ -112,7 +113,7 @@ class ActiveProfileReadHandle(SdkClientHandle):
async def current_handle(self) -> SdkClientHandle:
"""The handle of the live space, held for the asking task."""
space, identity = await asyncio.to_thread(self._resolve)
space, identity = await in_sdk_read(self._resolve)
mode, features = self.memory_mode(), self.current_features()
language = self.current_language()
key = (
@@ -127,9 +128,7 @@ 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 asyncio.to_thread(
self._build, space, identity, mode, features, language
)
handle = await in_sdk_read(self._build, space, identity, 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,12 +3,15 @@
from __future__ import annotations
import asyncio
import re
import threading
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
from contextlib import asynccontextmanager
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from one_memory.background_work import in_sdk_write
from one_memory.embedding_spaces import (
EmbeddingSpace,
EmbeddingSpaceStore,
@@ -28,6 +31,61 @@ from one_memory.model_profiles import ModelProfileSnapshot, ModelProfileStore
from one_memory.secrets import SecretStore, SecretStoreUnavailableError
PRODUCT_AGENT_ID = "all-apps"
#: SDK request ids whose write call is running on a thread right now. A write
#: the drain gave up on (its work ceiling, a lost lease) keeps running on its
#: thread; it must not be re-run beside itself (`recover_unknown`).
_IN_FLIGHT: set[str] = set()
_IN_FLIGHT_LOCK = threading.Lock()
def sdk_write_in_flight(request_id: str) -> bool:
with _IN_FLIGHT_LOCK:
return request_id in _IN_FLIGHT
class _Tracked:
"""One SDK write call, held in `_IN_FLIGHT` from submission until it ends.
If the wait for it is cancelled before a thread picked it up, it is
abandoned: it will not start, and it leaves `_IN_FLIGHT` at once.
"""
def __init__(self, request_id: str, work: Callable[..., Any]) -> None:
self.request_id = request_id
self.work = work
self.started = False
self.abandoned = False
with _IN_FLIGHT_LOCK:
_IN_FLIGHT.add(request_id)
def __call__(self, *args: Any, **kwargs: Any) -> Any:
with _IN_FLIGHT_LOCK:
if self.abandoned:
raise RuntimeError("hy_memory_write_abandoned")
self.started = True
try:
return self.work(*args, **kwargs)
finally:
with _IN_FLIGHT_LOCK:
_IN_FLIGHT.discard(self.request_id)
def abandon(self) -> None:
with _IN_FLIGHT_LOCK:
if not self.started:
self.abandoned = True
_IN_FLIGHT.discard(self.request_id)
async def _write(tracked_id: str, work: Callable[..., Any], /, *args: Any, **kwargs: Any) -> Any:
call = _Tracked(tracked_id, work)
try:
return await in_sdk_write(call, *args, **kwargs)
except asyncio.CancelledError:
call.abandon()
raise
_MESSAGE_ROLES = frozenset({"user", "assistant", "tool"})
_CONVERSATION_ROLES = frozenset({"user", "assistant"})
@@ -82,7 +140,8 @@ class SdkWriteProcessor:
if extracted and not self._lite:
contents = [sanitize_model_input(record.verbatim) for record in records]
await before_hy_invocation()
result = await asyncio.to_thread(
result = await _write(
request_id,
client.add_extracted,
contents,
user_id=scope.user_id,
@@ -109,7 +168,8 @@ class SdkWriteProcessor:
if not messages:
raise RuntimeError("hy_memory_task_without_inputs")
await before_hy_invocation()
result = await asyncio.to_thread(
result = await _write(
request_id,
client.add,
messages,
user_id=scope.user_id,
@@ -130,7 +190,7 @@ class SdkWriteProcessor:
if not request_id:
raise ValueError("request_id is required")
client = await self._client_handle.client()
outcome = await asyncio.to_thread(client.get_write_outcome, request_id)
outcome = await in_sdk_write(client.get_write_outcome, request_id)
if outcome is None:
return None
if not isinstance(outcome, Mapping):
@@ -509,11 +569,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.
+2 -1
View File
@@ -190,6 +190,7 @@
| `test_memory_write_registry.py` | `contract` | A3-01: acceptance and outcome recording for the unified write path. |
| `test_memory_write_routes.py` | `integration` | A3-01: the HTTP write entry, tested against a real FastAPI app. |
| `test_migrate_off_sqlite.py` | `contract` | A1-06: the migration is only done when reconciliation agrees. |
| `test_model_backoff.py` | `contract` | A broken model configuration must not turn a backlog into a request storm (#245). |
| `test_model_creation_api.py` | `integration` | Normal protected model creation routes with temporary data and in-memory secrets. |
| `test_model_creation_operations.py` | `contract` | 覆盖 lookup without table is read only and legacy create does not initialize、operation commits with profile and version without autoactivation、any replay conflicts before secret put 等 21 个场景。 |
| `test_model_creation_validation_boundary.py` | `integration` | Real ASGI validation must precede creation reservation and secret writes. |
@@ -319,4 +320,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. |
共 314 个测试文件。
共 315 个测试文件。
@@ -14,8 +14,8 @@ from pathlib import Path
import pytest
PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live")
EXPECTED_TEST_FILE_COUNT = 314
EXPECTED_TEST_FILES_SHA256 = "d745df3dfb964a8d2197c1ca3768728249d84ac4729305155ae1ef48428a19e4"
EXPECTED_TEST_FILE_COUNT = 315
EXPECTED_TEST_FILES_SHA256 = "681258b2a13ca852e273707071b286afe6b0b2fe537f90a8a2c64d83ec662772"
# Pure transformations, validation models, parity checks and state machines.
# This list is intentionally small: uncertain files default to contract.
@@ -324,3 +324,48 @@ def test_saturated_raw_reads_do_not_delay_memory_reads() -> None:
finally:
release_raw.set()
shutdown_bulk_pool()
def test_recall_reads_answer_while_every_sdk_write_thread_is_stuck() -> None:
"""A write that hangs in the SDK keeps its thread, not recall's (#231)."""
from one_memory.background_work import SDK_WRITE_WORKERS, in_sdk_read, in_sdk_write
stuck = threading.Barrier(SDK_WRITE_WORKERS + 1)
release = threading.Event()
def hung_write() -> None:
stuck.wait(timeout=2)
assert release.wait(5)
async def scenario() -> tuple[float, int]:
writes = [asyncio.create_task(in_sdk_write(hung_write)) for _ in range(SDK_WRITE_WORKERS)]
# One more than the pool holds: it waits for a write thread, as the
# drain's next write would.
queued = asyncio.create_task(in_sdk_write(lambda: None))
await asyncio.to_thread(stuck.wait, 2)
started = time.monotonic()
answer = await asyncio.wait_for(in_sdk_read(lambda: 7), 1)
elapsed = time.monotonic() - started
assert not queued.done()
release.set()
await asyncio.gather(*writes, queued)
return elapsed, answer
try:
elapsed, answer = asyncio.run(scenario())
assert answer == 7
assert elapsed < 0.2
finally:
release.set()
shutdown_bulk_pool()
def test_sdk_writes_run_on_their_own_bounded_threads() -> None:
from one_memory.background_work import SDK_WRITE_WORKERS, in_sdk_write, sdk_write_pool
try:
name = asyncio.run(in_sdk_write(lambda: threading.current_thread().name))
assert name.startswith("one-memory-sdk-write")
assert sdk_write_pool()._max_workers == SDK_WRITE_WORKERS
finally:
shutdown_bulk_pool()
@@ -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)})
@@ -336,6 +336,79 @@ def test_main_database_warns_when_wal_stays_above_the_high_water_mark(
assert any("main_database_wal_high" in record.message for record in caplog.records)
def test_a_wal_whose_frames_are_all_checkpointed_is_not_reported_high(
tmp_path, monkeypatch, caplog
):
"""#245: the alert judges frames still waiting, not the file's size."""
path = _database(tmp_path)
database = main_database(path)
monkeypatch.setattr(_access, "MAIN_DATABASE_CHECKPOINT_WRITE_INTERVAL", 1)
# One page: any frame left uncopied would cross it, a fully copied WAL not.
monkeypatch.setattr(_access, "MAIN_DATABASE_WAL_WARN_BYTES", 1)
caplog.set_level("WARNING", logger="one_memory.database_access")
with database.write() as db, db:
db.execute("UPDATE counter SET value = 1 WHERE id = 1")
assert database._wal_bytes() > 0
assert not any("main_database_wal_high" in record.message for record in caplog.records)
def test_the_writer_caps_the_wal_it_leaves_behind(tmp_path):
path = _database(tmp_path)
with main_database(path).write() as db:
limit = db.execute("PRAGMA journal_size_limit").fetchone()[0]
assert limit == _access.MAIN_DATABASE_JOURNAL_SIZE_LIMIT_BYTES
def test_an_idle_checkpoint_shrinks_the_wal_after_a_large_burst(tmp_path, monkeypatch):
"""#245: a WAL that grew once used to stay that size for good."""
path = _database(tmp_path)
database = main_database(path)
# No cadence checkpoint during the burst: the WAL has to grow.
monkeypatch.setattr(_access, "MAIN_DATABASE_CHECKPOINT_WRITE_INTERVAL", 10_000_000)
monkeypatch.setattr(_access, "MAIN_DATABASE_CHECKPOINT_SECONDS", 10_000_000.0)
database._last_checkpoint_at = time.monotonic()
with database.write() as db, db:
db.execute("PRAGMA wal_autocheckpoint = 0")
db.execute("CREATE TABLE blobs (body BLOB)")
db.executemany(
"INSERT INTO blobs VALUES (randomblob(?))", [(64 * 1024,) for _ in range(1_500)]
)
with database.write() as db:
db.execute("PRAGMA wal_autocheckpoint = 1000")
grown = database._wal_bytes()
assert grown > _access.MAIN_DATABASE_JOURNAL_SIZE_LIMIT_BYTES
with database.read() as reader:
reader.execute("SELECT COUNT(*) FROM blobs").fetchone()
# Someone is reading: no truncate is even attempted.
assert database.checkpoint_if_idle() is None
assert _access.checkpoint_idle_main_databases() == 1
assert database._wal_bytes() <= _access.MAIN_DATABASE_JOURNAL_SIZE_LIMIT_BYTES
with database.read() as reader:
assert reader.execute("SELECT COUNT(*) FROM blobs").fetchone() == (1_500,)
def test_a_busy_idle_checkpoint_is_skipped_not_raised(tmp_path):
path = _database(tmp_path)
database = main_database(path)
with database.write() as db, db:
db.execute("UPDATE counter SET value = 1 WHERE id = 1")
# Another process's reader holds a snapshot: TRUNCATE cannot finish.
outsider = sqlite3.connect(path)
try:
outsider.execute("BEGIN")
outsider.execute("SELECT value FROM counter").fetchone()
result = database.checkpoint_if_idle()
assert result is None or result[0] == 1
finally:
outsider.close()
assert database.checkpoint_if_idle() == (0, 0, 0)
assert database._wal_bytes() == 0
def test_main_database_maintenance_checkpoint_does_not_break_an_active_reader(
tmp_path, monkeypatch
):
@@ -661,6 +661,8 @@ def test_memory_add_routes_do_not_collide_with_single_memory_routes(tmp_path):
"task_id",
"memory_ids",
"error",
"error_code",
"next_retry_at",
}
status = await client.get(f"/api/v2/memories/additions/{request_id}")
assert status.json()["evidence_id"] == created.json()["evidence_id"]
+166 -5
View File
@@ -28,6 +28,8 @@ from one_memory.memory_features import (
FEATURE_NAMES,
FEATURES,
FEATURES_BY_NAME,
PARAMETERS,
SETTING_NAMES,
MemoryFeatures,
effective_features,
feature_activity,
@@ -46,15 +48,20 @@ TODAY = {
"session_episodes": False,
"episode_summary": False,
"intention": True,
"intention_dedup": True,
"few_shot": False,
}
#: The same for the numeric settings.
TODAY_PARAMETERS = {"intention_dedup_threshold": 0.95}
@pytest.fixture(autouse=True)
def _clean_sdk_env(monkeypatch):
"""The SDK's own env vars feed its defaults; tests start without them."""
for spec in FEATURES:
for spec in (*FEATURES, *PARAMETERS):
monkeypatch.delenv(spec.env, raising=False)
# The threshold falls back to the SDK's shared dedup variable.
monkeypatch.delenv("MEMORY_DEDUP_THRESHOLD", raising=False)
monkeypatch.delenv("ONE_MEMORY_GRAPH_STORE", raising=False)
@@ -95,17 +102,25 @@ def _field(config, name: str) -> bool:
def test_the_document_names_exactly_the_catalog() -> None:
assert tuple(MemoryFeatures.model_fields) == FEATURE_NAMES
assert tuple(MemoryFeatures.model_fields) == SETTING_NAMES
assert set(TODAY) == set(FEATURE_NAMES)
# Each feature is a MemoryConfig field the SDK has; no env var is written.
defaults = hy_sdk_runtime.sdk_feature_defaults()
assert set(defaults) == set(FEATURE_NAMES)
assert set(defaults) == set(SETTING_NAMES)
def test_the_defaults_are_todays_behaviour() -> None:
assert hy_sdk_runtime.sdk_feature_defaults() == TODAY
everything = {**TODAY, **TODAY_PARAMETERS}
assert hy_sdk_runtime.sdk_feature_defaults() == everything
assert MemoryFeatures().overrides() == ()
assert effective_features((), TODAY) == TODAY
assert effective_features((), everything) == everything
@pytest.mark.parametrize("profile", PROFILES, ids=("builtin", "remote"))
def test_intention_dedup_defaults_to_on_at_095_in_the_built_config(tmp_path, profile) -> None:
config = _config(tmp_path, profile)
assert config.recall.intention_dedup_enabled is True
assert config.recall.intention_dedup_threshold == 0.95
def test_the_sdk_env_var_still_moves_the_default_of_an_unset_feature(monkeypatch) -> None:
@@ -126,6 +141,32 @@ def test_unknown_or_non_boolean_values_are_refused() -> None:
)
@pytest.mark.parametrize("bad", (0.79, 1.01, -1, 2, True, "0.9", float("nan"), float("inf")))
def test_an_intention_dedup_threshold_outside_its_range_is_refused(bad) -> None:
with pytest.raises(ValidationError):
MemoryFeatures.model_validate({"intention_dedup_threshold": bad})
def test_the_intention_dedup_threshold_range_is_inclusive() -> None:
for value in (0.8, 0.9, 1, 1.0):
document = MemoryFeatures.model_validate({"intention_dedup_threshold": value})
assert document.overrides() == (("intention_dedup_threshold", float(value)),)
with pytest.raises(ValueError, match="invalid_memory_feature_value"):
hy_sdk_runtime.build_sdk_config(
PROFILES[0],
api_key=None,
data_dir=Path("/tmp/unused"),
features={"intention_dedup_threshold": 0.5},
)
def test_the_sdk_env_vars_still_move_the_threshold_default(monkeypatch) -> None:
monkeypatch.setenv("MEMORY_DEDUP_THRESHOLD", "0.9")
assert hy_sdk_runtime.sdk_feature_defaults()["intention_dedup_threshold"] == 0.9
monkeypatch.setenv("MEMORY_INTENTION_DEDUP_THRESHOLD", "0.97")
assert hy_sdk_runtime.sdk_feature_defaults()["intention_dedup_threshold"] == 0.97
def test_merge_sets_resets_and_leaves_absent_keys_alone() -> None:
saved = MemoryFeatures(graph_rerank=True, few_shot=True)
update = MemoryFeatures.model_validate({"few_shot": None, "episode_recall": True})
@@ -165,6 +206,20 @@ def test_each_feature_reaches_the_built_memory_config(tmp_path, profile) -> None
assert _field(config, other.name) is TODAY[other.name], (spec.name, other.name)
@pytest.mark.parametrize("profile", PROFILES, ids=("builtin", "remote"))
def test_intention_dedup_on_off_and_threshold_reach_the_built_memory_config(
tmp_path, profile
) -> None:
off = _config(tmp_path, profile, {"intention_dedup": False})
assert off.recall.intention_dedup_enabled is False
assert off.recall.intention_dedup_threshold == 0.95
on = _config(tmp_path, profile, {"intention_dedup": True, "intention_dedup_threshold": 0.9})
assert on.recall.intention_dedup_enabled is True
assert on.recall.intention_dedup_threshold == 0.9
# Nothing else moves.
assert _field(on, "intention") is True and _field(on, "graph_rerank") is False
@pytest.mark.parametrize("profile", PROFILES, ids=("builtin", "remote"))
def test_nothing_set_is_the_config_of_before_byte_for_byte(tmp_path, profile) -> None:
before = repr(dataclasses.asdict(_config(tmp_path, profile)))
@@ -172,6 +227,8 @@ def test_nothing_set_is_the_config_of_before_byte_for_byte(tmp_path, profile) ->
assert repr(dataclasses.asdict(_config(tmp_path, profile, {}))) == before
# Setting every feature to its default value changes nothing either.
assert repr(dataclasses.asdict(_config(tmp_path, profile, TODAY))) == before
everything = {**TODAY, **TODAY_PARAMETERS}
assert repr(dataclasses.asdict(_config(tmp_path, profile, everything))) == before
@pytest.mark.parametrize("profile", PROFILES, ids=("builtin", "remote"))
@@ -190,6 +247,7 @@ def test_features_never_change_the_collection_identity(tmp_path, profile) -> Non
{name: True for name in FEATURE_NAMES},
{name: False for name in FEATURE_NAMES},
{"graph_rerank": True, "episode_recall": True},
{"intention_dedup": False, "intention_dedup_threshold": 0.8},
):
assert identity(_config(tmp_path, profile, values)) == baseline
@@ -243,6 +301,13 @@ def test_a_feature_needing_a_higher_mode_is_reported_inactive() -> None:
series = effective_features((("same_series_timeline", True), ("graph_rerank", True)), TODAY)
assert feature_activity("same_series_timeline", series, "pro") == "requires_ultra"
assert feature_activity("same_series_timeline", series, "ultra") is None
defaults = {**TODAY, **TODAY_PARAMETERS}
assert feature_activity("intention_dedup", defaults, "pro") is None
assert feature_activity("intention_dedup_threshold", defaults, "pro") is None
assert feature_activity("intention_dedup_threshold", defaults, "lite") == "requires_pro"
off = effective_features((("intention_dedup", False),), defaults)
assert feature_activity("intention_dedup", off, "pro") == "disabled"
assert feature_activity("intention_dedup_threshold", off, "pro") == "requires_intention_dedup"
# --- rebuilding handles -------------------------------------------------------
@@ -497,3 +562,99 @@ def test_features_api_get_put_reset_and_status(tmp_path, monkeypatch) -> None:
assert (await client.get("/api/v1/settings/memory-features")).json()["non_default"] == {}
run_app(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_intention_dedup_api_on_off_threshold_and_422(tmp_path, monkeypatch) -> None:
built: list[tuple[bool, float]] = []
original = hy_sdk_runtime.build_sdk_config
def recording(*args, **kwargs):
config = original(*args, **kwargs)
built.append(
(config.recall.intention_dedup_enabled, config.recall.intention_dedup_threshold)
)
return config
monkeypatch.setattr(hy_sdk_runtime, "build_sdk_config", recording)
def built_now(app) -> tuple[bool, float]:
"""What a client built with the read handle's current key carries."""
recording(
hy_sdk_runtime.builtin_retrieval_snapshot(),
api_key=None,
data_dir=tmp_path,
features=app.state.sdk_client_handle.current_features(),
)
return built[-1]
async def scenario(app, client):
path = "/api/v1/settings/memory-features"
current = (await client.get(path)).json()
dedup = _by_name(current)["intention_dedup"]
assert dedup["enabled"] is True and dedup["default"] is True
assert dedup["configured"] is None and dedup["active"] is True
assert dedup["sdk_field"] == "recall.intention_dedup_enabled"
assert dedup["env"] == "MEMORY_INTENTION_DEDUP_ENABLED"
(threshold,) = current["parameters"]
assert threshold == {
"name": "intention_dedup_threshold",
"value": 0.95,
"configured": None,
"default": 0.95,
"minimum": 0.8,
"maximum": 1.0,
"active": True,
"reason": None,
"requires_feature": "intention_dedup",
"sdk_field": "recall.intention_dedup_threshold",
"env": "MEMORY_INTENTION_DEDUP_THRESHOLD",
"description": threshold["description"],
}
assert built_now(app) == (True, 0.95)
off = await client.put(path, json={"intention_dedup": False})
assert off.status_code == 200, off.text
body = off.json()
assert body["changed"] == ["intention_dedup"]
assert body["non_default"] == {"intention_dedup": False}
assert body["parameters"][0]["active"] is False
assert body["parameters"][0]["reason"] == "requires_intention_dedup"
assert app.state.sdk_write_coordinator.memory_features() == (("intention_dedup", False),)
assert built_now(app) == (False, 0.95)
on = (
await client.put(path, json={"intention_dedup": True, "intention_dedup_threshold": 0.9})
).json()
assert on["changed"] == ["intention_dedup", "intention_dedup_threshold"]
assert on["non_default"] == {"intention_dedup_threshold": 0.9}
assert on["parameters"][0]["value"] == 0.9 and on["parameters"][0]["active"] is True
expected = (("intention_dedup", True), ("intention_dedup_threshold", 0.9))
assert app.state.sdk_client_handle.current_features() == expected
assert built_now(app) == (True, 0.9)
processing = (await client.get("/api/v1/memory-processing")).json()
assert processing["pipeline"]["features"] == {"intention_dedup_threshold": 0.9}
saved = json.loads((tmp_path / "data" / "config.json").read_text())
assert saved["memory_features"] == {
"intention_dedup": True,
"intention_dedup_threshold": 0.9,
}
for bad in (0.5, 0.79, 1.5, -0.1, "0.9", True):
response = await client.put(path, json={"intention_dedup_threshold": bad})
assert response.status_code == 422, bad
assert (await client.put(path, json={"intention_dedup": "false"})).status_code == 422
# A refused body changes nothing.
assert app.state.sdk_client_handle.current_features() == expected
reset = (
await client.put(
path, json={"intention_dedup": None, "intention_dedup_threshold": None}
)
).json()
assert reset["changed"] == ["intention_dedup_threshold"]
assert reset["non_default"] == {}
assert app.state.sdk_client_handle.current_features() == ()
assert built_now(app) == (True, 0.95)
run_app(tmp_path, monkeypatch, scenario)
@@ -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)
@@ -624,6 +643,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 +694,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,6 +809,7 @@ 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"
@@ -708,6 +817,7 @@ def test_mode_api_get_put_and_its_effects(tmp_path, monkeypatch) -> None:
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"]
assert app.state.sdk_write_coordinator.memory_mode() == "lite"
assert _sdk_client_handle(app).memory_mode() == "lite"
@@ -741,6 +851,65 @@ 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_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):
@@ -1433,25 +1433,6 @@ def test_a_claimed_row_with_no_readable_payload_is_settled_not_left_running(tmp_
assert _rows(path)[0]["lease_owner"] is None
def test_an_unknown_outcome_is_recorded_as_unknown_not_retried(tmp_path) -> None:
path = tmp_path / "one-memory.sqlite3"
queue = DurableWriteQueue(path)
record = queue.submit(_request())
supervisor = _Recorder(error=RuntimeError("model_content_outcome_unknown"))
_claim_through_drain(queue, record.job_id)
asyncio.run(
execute_job(
record.job_id,
queue=queue,
supervisor=supervisor,
evidence=_EvidenceStore({"e1"}),
)
)
assert _state(path, record.job_id) == "unknown"
def test_unpermitted_evidence_parks_and_is_readmitted_after_confirmation(tmp_path) -> None:
"""A note claimed before its confirmation must not become terminal failed."""
path = tmp_path / "one-memory.sqlite3"
@@ -1478,34 +1459,6 @@ def test_unpermitted_evidence_parks_and_is_readmitted_after_confirmation(tmp_pat
assert replay.state == PENDING
def test_a_permission_error_after_hy_invocation_stays_unknown(tmp_path) -> None:
"""An error code cannot erase the durable possibly-sent boundary."""
path = tmp_path / "one-memory.sqlite3"
queue = DurableWriteQueue(path)
record = queue.submit(_request())
_claim_through_drain(queue, record.job_id)
asyncio.run(
execute_job(
record.job_id,
queue=queue,
supervisor=_Recorder(
error=RuntimeError("model_content_evidence_changed"),
invoke_hy=True,
),
evidence=_EvidenceStore({"e1"}),
)
)
assert queue.get(record.request_id).state == UNKNOWN
with sqlite3.connect(path) as connection:
assert connection.execute(
"SELECT hy_invoked,last_error FROM memory_write_jobs WHERE job_id=?",
(record.job_id,),
).fetchone() == (1, "model_content_evidence_changed")
assert queue.reclaim_expired() == 0
def test_replayed_live_evidence_readmits_a_pre_send_identity_failure(tmp_path) -> None:
path = tmp_path / "one-memory.sqlite3"
queue = DurableWriteQueue(path)
@@ -1623,90 +1576,6 @@ def test_startup_never_repairs_a_possibly_sent_identity_failure(tmp_path) -> Non
assert queue.get(job.request_id).state == UNKNOWN
def test_reconciled_absent_timeout_is_requeued_once_without_resetting_attempts(
tmp_path,
) -> None:
path = tmp_path / "one-memory.sqlite3"
queue = DurableWriteQueue(path)
record = queue.submit(_request())
with sqlite3.connect(path) as connection, connection:
connection.execute(
"UPDATE memory_write_jobs SET state='unknown', attempts=1, "
"hy_invoked=1, last_error='hy_memory_timeout', updated_at=? WHERE job_id=?",
("2026-01-01T00:00:00+00:00", record.job_id),
)
assert (
queue.retry_timed_out_unknown_after_reconcile(
older_than=datetime(2026, 1, 1, 0, 5, tzinfo=UTC)
)
== 1
)
with sqlite3.connect(path) as connection:
state, attempts, invoked, error = connection.execute(
"SELECT state, attempts, hy_invoked, last_error FROM memory_write_jobs WHERE job_id=?",
(record.job_id,),
).fetchone()
assert (state, attempts, invoked, error) == (
"pending",
1,
0,
"model_content_reconciled_absent_retry",
)
def test_unresolved_unknown_is_automatically_retried_only_once(tmp_path) -> None:
path = tmp_path / "one-memory.sqlite3"
queue = DurableWriteQueue(path)
record = queue.submit(_request())
with sqlite3.connect(path) as connection, connection:
connection.execute(
"UPDATE memory_write_jobs SET state='unknown',attempts=1,hy_invoked=1,"
"last_error='model_content_outcome_unknown',updated_at=? WHERE job_id=?",
("2026-01-01T00:00:00+00:00", record.job_id),
)
cutoff = datetime(2026, 1, 1, 0, 5, tzinfo=UTC)
assert queue.retry_first_unknown_after_reconcile(older_than=cutoff) == 1
first_retry = queue.get(record.request_id)
assert first_retry.state == PENDING
with sqlite3.connect(path) as connection:
assert (
connection.execute(
"SELECT attempts FROM memory_write_jobs WHERE job_id=?",
(record.job_id,),
).fetchone()[0]
== 1
)
drain = DurableWriteDrain(queue, execute=_never_execute, owner="retry-owner")
assert drain.claim(limit=1) == (record.job_id,)
assert queue.mark_invoked(record.job_id, owner="retry-owner")
queue.settle(
record.job_id,
state=UNKNOWN,
owner="retry-owner",
error_code="model_content_outcome_unknown",
hy_invoked=True,
)
with sqlite3.connect(path) as connection:
assert (
connection.execute(
"SELECT attempts FROM memory_write_jobs WHERE job_id=?",
(record.job_id,),
).fetchone()[0]
== 2
)
assert (
queue.retry_first_unknown_after_reconcile(
older_than=datetime.now(UTC) + timedelta(minutes=1)
)
== 0
)
assert queue.get(record.request_id).state == UNKNOWN
def test_drain_runs_unknown_reconciliation_during_busy_safe_maintenance(tmp_path) -> None:
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
calls = 0
@@ -1759,26 +1628,6 @@ def test_a_reclaimed_uninvoked_job_with_attempts_left_is_retryable(tmp_path) ->
assert _rows(path)[0]["attempts"] == 0
def test_post_invocation_transport_failure_stays_unknown_not_pending(tmp_path) -> None:
"""Once the bridge may have been opened, no error is safe to replay."""
path = tmp_path / "one-memory.sqlite3"
queue = DurableWriteQueue(path)
record = queue.submit(_request())
_claim_through_drain(queue, record.job_id)
asyncio.run(
execute_job(
record.job_id,
queue=queue,
supervisor=_Recorder(error=RuntimeError("hy_memory_timeout")),
evidence=_EvidenceStore({"e1"}),
)
)
assert _state(path, record.job_id) == UNKNOWN
assert _rows(path)[0]["hy_invoked"] == 1
@pytest.mark.parametrize(
"code,expected",
[
@@ -1900,7 +1749,9 @@ def test_a_failed_job_logs_where_it_failed_but_never_the_message(tmp_path, caplo
for message in caplog.records
if "durable_write_job_failed" in message.getMessage()
]
assert f"job_id={record.job_id} error_code=RuntimeError chain=RuntimeError@" in line
assert (
f"job_id={record.job_id} error_code=RuntimeError state=pending chain=RuntimeError@" in line
)
assert ";AttributeError@" in line
assert "test_memory_server_durable_queue.py:" in line and ":closed_store" in line
assert "secret diary" not in line
@@ -2620,57 +2471,6 @@ def test_a_wedged_job_hits_the_work_ceiling_and_settles_unknown(tmp_path) -> Non
assert (state, owner, expiry) == ("unknown", None, None)
def test_user_confirmed_unknown_retry_requeues_job_and_linked_tasks(tmp_path) -> None:
path = tmp_path / "one-memory.sqlite3"
queue = DurableWriteQueue(path)
record = queue.submit(_request(evidence=("e1",)))
with sqlite3.connect(path) as connection, connection:
connection.execute(
"UPDATE memory_write_jobs SET state='unknown', attempts=2, hy_invoked=1, "
"last_error='hy_memory_timeout' WHERE job_id=?",
(record.job_id,),
)
connection.executescript(
"""
CREATE TABLE memory_tasks (
task_id TEXT PRIMARY KEY,
status TEXT NOT NULL,
runner_token TEXT,
attempts INTEGER NOT NULL,
last_error_code TEXT,
updated_at TEXT NOT NULL
);
CREATE TABLE memory_task_inputs (
task_id TEXT PRIMARY KEY,
refs_json TEXT NOT NULL
);
"""
)
connection.execute(
"INSERT INTO memory_tasks VALUES (?,?,?,?,?,?)",
("task-1", "unknown", None, 2, "model_content_outcome_unknown", "old"),
)
connection.execute(
"INSERT INTO memory_task_inputs VALUES (?,?)",
("task-1", '[{"evidence_id":"e1"}]'),
)
assert queue.retry_unknown_confirmed(record.job_id) == 1
retried = queue.get(record.request_id)
assert retried is not None
assert retried.state == PENDING
with sqlite3.connect(path) as connection:
assert connection.execute(
"SELECT attempts, hy_invoked FROM memory_write_jobs WHERE job_id=?",
(record.job_id,),
).fetchone() == (0, 0)
assert connection.execute(
"SELECT status, attempts, last_error_code FROM memory_tasks WHERE task_id='task-1'"
).fetchone() == ("pending", 0, "model_content_manual_retry_requested")
assert queue.retry_unknown_confirmed(record.job_id) == 0
def test_restart_redrains_a_job_claimed_by_a_worker_that_died(tmp_path) -> None:
"""End-to-end restart recovery: a claimed-but-not-run job runs once.
@@ -2922,30 +2722,6 @@ def test_forever_drain_admits_a_new_source_while_an_earlier_source_is_running(tm
assert queue.get("workbuddy-1").state == COMPLETED
def test_sqlite_drain_preserves_unknown_after_execute_job(tmp_path) -> None:
"""A drain failure after invocation must remain unknown, not look retryable."""
path = tmp_path / "one-memory.sqlite3"
queue = DurableWriteQueue(path)
queue.submit(_request(evidence=("e1",)))
async def execute(job_id: str) -> None:
await execute_job(
job_id,
queue=queue,
supervisor=_Recorder(error=RuntimeError("model_content_outcome_unknown")),
evidence=_EvidenceStore({"e1"}),
)
async def scenario() -> None:
drain = DurableWriteDrain(queue, execute=execute)
assert await drain.run_one() == 1
asyncio.run(scenario())
assert queue.get("idem-1").state == "unknown"
assert queue.reclaim_expired() == 0
assert queue.get("idem-1").state == "unknown"
def test_sqlite_drain_keeps_running_after_an_executor_raises(tmp_path) -> None:
"""One executor exception must not kill the drain or strand other rows.
@@ -4222,27 +3998,6 @@ def test_set_state_rejects_an_unvalidated_state(tmp_path) -> None:
assert registry.get(record.request_id).state == "accepted"
def test_set_state_cannot_resolve_unknown_without_an_inquiry(tmp_path) -> None:
"""The HTTP-facing setter must not bypass the inquiry-only exit.
`DurableWriteQueue.settle` deliberately treats `unknown` as sticky; a
direct `set_state("completed"/"failed")` would otherwise turn a
possibly-delivered write into a terminal claim without asking HY. The
durable registry is the shared caller surface, so it must preserve that
boundary even though today only tests call it.
"""
from one_memory.memory_server.durable_queue import DurableWriteRegistry
path = tmp_path / "one-memory.sqlite3"
registry = DurableWriteRegistry(path)
durable = registry.queue.submit(_request())
registry.queue.settle(durable.job_id, state=UNKNOWN, hy_invoked=True)
assert registry.set_state(durable.request_id, "completed") is None
assert registry.set_state(durable.request_id, "failed") is None
assert registry.get(durable.request_id).state == UNKNOWN
@pytest.mark.parametrize("state", (WAITING_FOR_PERMISSION, WAITING_FOR_MODEL))
def test_set_state_accepts_the_route_parked_states(tmp_path, state: str) -> None:
"""The durable setter must accept every non-terminal route state.
@@ -5435,83 +5190,6 @@ def test_readmit_paths_never_requeue_a_possibly_sent_row(tmp_path) -> None:
assert DurableWriteDrain(queue, execute=_never_execute).claim(limit=5) == ()
def test_a_refused_source_context_is_nonblocking_unknown(
tmp_path,
) -> None:
"""An ambiguous call-boundary failure is retained but never blocks FIFO."""
path = tmp_path / "one-memory.sqlite3"
queue = DurableWriteQueue(path)
record = queue.submit(_request(key="refused-scope", source="opencode"))
claimer = DurableWriteDrain(queue, execute=_never_execute, owner="refused-owner")
assert claimer.claim(limit=1) == (record.job_id,)
asyncio.run(
execute_job(
record.job_id,
queue=queue,
supervisor=_Recorder(
error=RuntimeError("hy_memory_write_verification_context_scope"),
invoke_hy=True,
),
evidence=_EvidenceStore({"e1"}),
owner="refused-owner",
)
)
with sqlite3.connect(path.as_uri() + "?mode=ro", uri=True) as db:
state, invoked, last_error = db.execute(
"SELECT state, hy_invoked, last_error FROM memory_write_jobs WHERE job_id=?",
(record.job_id,),
).fetchone()
assert state == UNKNOWN
assert invoked == 1
assert last_error == "hy_memory_write_verification_context_scope"
assert DurableWriteDrain(queue, execute=_never_execute).claim(limit=1) == ()
def test_a_failure_after_the_call_still_parks_as_unknown(tmp_path) -> None:
"""The fix must not widen: a post-call refusal is still possibly-sent."""
path = tmp_path / "one-memory.sqlite3"
queue = DurableWriteQueue(path)
record = queue.submit(_request(key="after-call", source="opencode"))
claimer = DurableWriteDrain(queue, execute=_never_execute, owner="after-call-owner")
assert claimer.claim(limit=1) == (record.job_id,)
recorder = _Recorder(
error=RuntimeError("hy_memory_write_verification_authority_invalid"),
invoke_hy=True,
)
asyncio.run(
execute_job(
record.job_id,
queue=queue,
supervisor=recorder,
evidence=_EvidenceStore({"e1"}),
owner="after-call-owner",
)
)
with sqlite3.connect(path.as_uri() + "?mode=ro", uri=True) as db:
state, invoked = db.execute(
"SELECT state, hy_invoked FROM memory_write_jobs WHERE job_id=?",
(record.job_id,),
).fetchone()
assert state == UNKNOWN
assert invoked == 1
assert len(recorder.seen) == 1
assert (
queue.retry_first_unknown_after_reconcile(
older_than=datetime.now(UTC) + timedelta(minutes=1)
)
== 0
)
assert queue.retry_unknown_confirmed(record.job_id) == 0
assert DurableWriteDrain(queue, execute=_never_execute).claim(limit=1) == ()
assert len(recorder.seen) == 1
def test_event_time_not_enqueue_order_drives_the_window(
tmp_path,
) -> None:
@@ -5674,3 +5352,93 @@ def test_hand_added_memories_still_run_one_at_a_time_in_order(tmp_path) -> None:
assert drain.claim(limit=5) == ()
assert queue.get("note-2").state == PENDING
assert second.job_id
def test_a_job_the_execute_gate_releases_is_not_reclaimed_while_it_still_refuses(
tmp_path,
) -> None:
"""The claim gate and the execute gate can disagree for good (#245).
The claim asks about the lane's user, the execute gate about the user of
the job's Evidence. An id-only pre-upgrade payload sits in the `local`
lane even when its Evidence belongs to someone else: while that user
evolves, the claim admits the job and the execute gate hands it back.
Released with no hold, the very next tick claimed it again, in a loop.
"""
path = tmp_path / "one-memory.sqlite3"
queue = DurableWriteQueue(path)
record = queue.submit(_request(key="evolving-user", source="cursor"))
drain = DurableWriteDrain(queue, execute=_never_execute, owner="gate-owner")
assert drain.claim(limit=1) == (record.job_id,)
asyncio.run(
execute_job(
record.job_id,
queue=queue,
supervisor=_Recorder(error=RuntimeError("memory_evolve_in_progress")),
evidence=_EvidenceStore({"e1"}),
owner="gate-owner",
)
)
assert queue.get(record.request_id).state == PENDING
now = datetime.now(UTC)
assert drain.claim(limit=1, now=now) == ()
assert drain.claim(limit=1, now=now + timedelta(seconds=4)) == ()
assert drain.claim(limit=1, now=now + timedelta(seconds=6)) == (record.job_id,)
with sqlite3.connect(path) as connection:
assert connection.execute(
"SELECT attempts,hy_invoked FROM memory_write_jobs WHERE job_id=?",
(record.job_id,),
).fetchone() == (1, 0)
def _suspension_runtime(queue: DurableWriteQueue, enabled: set[str]):
from one_memory.sdk_memory_runtime import SdkMemoryRuntime
return SdkMemoryRuntime(
coordinator=SimpleNamespace(),
read_handle=SimpleNamespace(), # type: ignore[arg-type]
write_queue=queue,
extraction_settings=SimpleNamespace(), # type: ignore[arg-type]
data_dir=queue.database_path.parent,
source_active=lambda source_key: source_key in enabled,
)
def test_a_disabled_source_suspends_its_jobs_and_enabling_it_resumes_them(tmp_path) -> None:
"""A source the user turned off keeps its jobs, out of the pending count (#245)."""
path = tmp_path / "one-memory.sqlite3"
queue = DurableWriteQueue(path)
recent = datetime.now(UTC)
for index in range(3):
queue.submit(_request(key=f"cursor-{index}", source="cursor", event_at=recent))
codex = queue.submit(_request(key="codex-0", source="codex", event_at=recent))
enabled = {"cursor", "codex"}
runtime = _suspension_runtime(queue, enabled)
drain = DurableWriteDrain(
queue,
execute=_never_execute,
owner="suspend-owner",
source_writes_permitted=lambda source_key: source_key in enabled,
)
assert runtime.status_counts() == {PENDING: 4}
assert runtime.queue_depths().fresh == 4
enabled.discard("cursor")
assert runtime.status_counts() == {PENDING: 1, "suspended": 3}
assert runtime.queue_depths().fresh == 1
assert drain.claim(limit=10) == (codex.job_id,)
queue.settle(codex.job_id, state=COMPLETED, hy_invoked=True, owner="suspend-owner")
assert drain.claim(limit=10) == ()
assert runtime.status_counts() == {COMPLETED: 1, "suspended": 3}
# Nothing was cancelled, moved or deleted: the rows are as they were.
assert [row["state"] for row in _rows(path) if row["source_key"] == "cursor"] == [PENDING] * 3
enabled.add("cursor")
assert runtime.status_counts() == {PENDING: 3, COMPLETED: 1}
assert runtime.queue_depths().fresh == 3
assert len(drain.claim(limit=10)) == 1
+3 -3
View File
@@ -1181,7 +1181,7 @@ def test_authority_invalid_task_is_never_reinvoked(tmp_path) -> None:
assert retained.last_error_code == "hy_memory_write_verification_authority_invalid"
def test_linked_unknown_is_retried_only_by_the_durable_job(tmp_path) -> None:
def test_linked_unknown_is_rerun_only_by_the_durable_job(tmp_path) -> None:
from one_memory.memory_server.contracts import BatchIdentity, WriteRequest
from one_memory.memory_server.durable_queue import DurableWriteQueue
@@ -1241,8 +1241,8 @@ def test_linked_unknown_is_retried_only_by_the_durable_job(tmp_path) -> None:
cutoff = datetime(2026, 1, 1, 0, 5, tzinfo=UTC)
assert store.retry_first_unknown_after_reconcile(older_than=cutoff) == 0
assert store.require(task.task_id).status is MemoryTaskStatus.UNKNOWN
assert queue.retry_first_unknown_after_reconcile(older_than=cutoff) == 1
assert store.require(task.task_id).status is MemoryTaskStatus.PENDING
# The durable job is the only execution entry: it re-runs itself.
assert queue.recover_unknown() == 1
assert queue.get(job.request_id).state == "pending"
assert (
store.active_window(
@@ -0,0 +1,864 @@
"""A broken model configuration must not turn a backlog into a request storm (#245)."""
from __future__ import annotations
import asyncio
import sqlite3
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
import httpx
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.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,
PENDING,
DurableWriteDrain,
DurableWriteQueue,
execute_job,
)
from one_memory.model_backoff import (
BACKOFF_CAP_SECONDS,
BackedOffModelTransport,
ModelBackoff,
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"
EMBEDDINGS = "https://models.example.com/v1/embeddings"
class _Clock:
def __init__(self) -> None:
self.now = 0.0
def __call__(self) -> float:
return self.now
def _request(index: int) -> WriteRequest:
session = f"sess-{index}"
return WriteRequest(
internal_session_id=session,
batch_identity=BatchIdentity(internal_session_id=session, first_round=1, last_round=5),
evidence_ids=[f"e-{index}"],
idempotency_key=f"idem-{index}",
# Twenty sources, so the drain could claim ten jobs at once.
source_id=f"source-{index % 20}",
)
async def _never_execute(job_id: str) -> None:
raise AssertionError(f"the simulation settles {job_id} itself")
def _send(transport: BackedOffModelTransport, url: str = CHAT) -> int | None:
try:
response = asyncio.run(transport.handle_async_request(httpx.Request("POST", url)))
except ModelBackoffRefusal:
return None
return response.status_code
def test_the_pause_doubles_from_five_seconds_and_stops_at_three_minutes() -> None:
assert [backoff_seconds(level) for level in range(1, 9)] == [
5.0,
10.0,
20.0,
40.0,
80.0,
160.0,
180.0,
180.0,
]
assert backoff_seconds(1000) == BACKOFF_CAP_SECONDS == 180.0
def test_an_always_failing_provider_sees_a_bounded_number_of_calls(tmp_path) -> None:
"""200 pending jobs against a provider that refuses every one.
Before the pause the drain claimed the next job the moment the last one
failed: hundreds of requests a minute. Now the calls follow the schedule.
"""
clock = _Clock()
backoff = ModelBackoff(clock=clock)
calls: list[float] = []
def provider(_request: httpx.Request) -> httpx.Response:
calls.append(clock.now)
return httpx.Response(401)
transport = BackedOffModelTransport(
backoff, profile_id="p:1", inner=httpx.MockTransport(provider)
)
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
for index in range(200):
queue.submit(_request(index))
drain = DurableWriteDrain(
queue,
execute=_never_execute,
owner="sim",
model_claim_allowance=backoff.allowance,
)
claimed = 0
for tick in range(6001):
# Ten minutes in tenths of a second, without float drift.
clock.now = tick / 10
for job_id in drain.claim(limit=10):
claimed += 1
_send(transport)
queue.settle(job_id, state=FAILED, owner="sim", error_code="model_auth_failed")
# The first claim was taken before anything had failed: two of its
# requests reached the provider and the rest were refused locally. After
# that one probe per pause: 5, 10, 20, 40, 80, 160, then 180 seconds.
assert calls[:2] == [0.0, 0.0]
probes = calls[2:]
assert probes == pytest.approx([5.0, 15.0, 35.0, 75.0, 155.0, 315.0, 495.0], abs=0.11)
gaps = [later - earlier for earlier, later in zip(probes, probes[1:], strict=False)]
assert max(gaps) <= BACKOFF_CAP_SECONDS + 0.11
assert len(calls) == 9
assert claimed == 17
with sqlite3.connect(tmp_path / "one-memory.sqlite3") as database:
(pending,) = database.execute(
"SELECT COUNT(*) FROM memory_write_jobs WHERE state=?", (PENDING,)
).fetchone()
assert pending == 200 - claimed
def test_a_success_ends_the_pause_at_once() -> None:
clock = _Clock()
backoff = ModelBackoff(clock=clock)
replies = [httpx.Response(503), httpx.Response(503), httpx.Response(200)]
transport = BackedOffModelTransport(
backoff, profile_id="p:1", inner=httpx.MockTransport(lambda _r: replies.pop(0))
)
assert _send(transport) == 503
clock.now = 5.0
assert backoff.allowance() == 1
assert _send(transport) == 503
assert backoff.allowance() == 0
assert backoff.state().last_error == "model_server_error"
clock.now = 15.0
assert backoff.allowance() == 1
assert _send(transport) == 200
assert backoff.allowance() is None
assert backoff.state().until is None
assert backoff.state().failures == 0
def test_a_working_embedding_endpoint_does_not_clear_a_broken_chat_model() -> None:
clock = _Clock()
backoff = ModelBackoff(clock=clock)
def provider(request: httpx.Request) -> httpx.Response:
return httpx.Response(200 if request.url.path.endswith("/embeddings") else 404)
transport = BackedOffModelTransport(
backoff, profile_id="p:1", inner=httpx.MockTransport(provider)
)
assert _send(transport) == 404
assert _send(transport) == 404
# Recall's embeddings still go out while the chat model is refused.
assert _send(transport, EMBEDDINGS) == 200
assert _send(transport) is None
assert backoff.state().last_error == "model_not_found"
assert backoff.paused()
def test_an_unreachable_provider_is_paused_like_a_refusing_one() -> None:
clock = _Clock()
backoff = ModelBackoff(clock=clock)
def provider(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("name resolution failed", request=request)
transport = BackedOffModelTransport(
backoff, profile_id="p:1", inner=httpx.MockTransport(provider)
)
with pytest.raises(httpx.ConnectError):
_send(transport)
assert backoff.state().last_error == "model_unreachable"
assert backoff.allowance() == 0
def test_a_request_problem_is_left_to_its_job() -> None:
backoff = ModelBackoff(clock=_Clock())
transport = BackedOffModelTransport(
backoff, profile_id="p:1", inner=httpx.MockTransport(lambda _r: httpx.Response(400))
)
assert _send(transport) == 400
assert backoff.allowance() is None
def test_a_configuration_change_ends_the_pause() -> None:
clock = _Clock()
backoff = ModelBackoff(clock=clock)
for _ in range(6):
clock.now += 200.0
backoff.record_failure("model_auth_failed", "p:1", "chat")
assert backoff.state().until is not None
backoff.reset()
assert backoff.allowance() is None
assert backoff.state().until is None
# A new revision of the profile is a new configuration as well.
backoff.record_failure("model_auth_failed", "p:1", "chat")
backoff.record_failure("model_auth_failed", "p:2", "chat")
assert backoff.state().failures == 1
def test_a_probe_that_never_reports_does_not_hold_the_queue_forever() -> None:
clock = _Clock()
backoff = ModelBackoff(clock=clock)
backoff.record_failure("model_timeout", "p:1", "chat")
clock.now = 5.0
assert backoff.allowance() == 1
assert backoff.allowance() == 0
clock.now = 35.0
assert backoff.allowance() == 1
def test_memory_processing_shows_the_pause_and_a_profile_save_ends_it(tmp_path) -> None:
async def scenario() -> None:
app = create_app(
Settings(
data_dir=tmp_path / "data",
source_home=tmp_path,
api_token=SecretStr("local-token"),
),
secret_store=InMemorySecretStore(),
)
headers = {"Authorization": "Bearer local-token", "Origin": "http://127.0.0.1:5173"}
async with (
app.router.lifespan_context(app),
AsyncClient(
transport=ASGITransport(app=app), base_url="http://test", headers=headers
) as client,
):
created = await client.post(
"/api/v1/model-profiles",
json={
"name": "Provider",
"provider_kind": "openai_compatible",
"base_url": "https://models.example.com/v1",
"model": "memory-model",
"embedding_model": "embedding-model",
"embedding_base_url": "https://models.example.com/v1",
"api_key": "api-route-secret",
},
)
assert created.status_code == 200
backoff: ModelBackoff = app.state.model_backoff
backoff.record_failure("model_auth_failed", "p:1", "chat")
paused = (await client.get("/api/v1/memory-processing")).json()
assert paused["llm_backoff_error"] == "model_auth_failed"
until = datetime.fromisoformat(paused["llm_backoff_until"])
assert until > datetime.now(UTC)
assert paused["suspended"] == 0
saved = await client.patch(
f"/api/v1/model-profiles/{created.json()['profile_id']}",
json={"expected_revision": created.json()["revision"], "model": "other-model"},
)
assert saved.status_code == 200
cleared = (await client.get("/api/v1/memory-processing")).json()
assert cleared["llm_backoff_until"] is None
assert cleared["llm_backoff_error"] is None
assert backoff.allowance() is None
asyncio.run(scenario())
# --- Every write retries itself: provider failures wait, others retry 3 times ---
class _FakeSdk:
"""Stands in for the SDK write path, with its real replay rule.
Like `HyMemoryClient`, it keeps one outcome per request id and replays a
failed one without calling the model. Every write goes out through the
breaker's transport, so the provider sees exactly what MemHub sends.
"""
def __init__(self, transport: BackedOffModelTransport | None = None) -> None:
self.transport = transport
self.outcomes: dict[str, str] = {}
self.written: list[str] = []
self.request_ids: list[str] = []
self.fail_with: str | None = None
async def process_sdk_job(self, records, *, request_id, before_hy_invocation):
await before_hy_invocation()
self.request_ids.append(request_id)
if request_id in self.outcomes:
if self.outcomes[request_id] == "failed":
raise RuntimeError("hy_sdk_write_failed")
raise AssertionError("a succeeded id is never sent again")
if self.fail_with is not None:
self.outcomes[request_id] = "failed"
raise RuntimeError(self.fail_with)
if self.transport is not None:
try:
response = await self.transport.handle_async_request(httpx.Request("POST", CHAT))
except httpx.TransportError:
self.outcomes[request_id] = "unknown"
raise
if response.status_code >= 400:
self.outcomes[request_id] = "failed"
raise RuntimeError("hy_sdk_write_failed")
self.outcomes[request_id] = "succeeded"
self.written.extend(record.evidence_id for record in records)
return SimpleNamespace(raw_ids=(f"raw-{request_id}",), memory_ids=(f"m-{request_id}",))
async def inquire_write_outcome(self, request_id):
if self.outcomes.get(request_id) != "succeeded":
return None
return SimpleNamespace(raw_ids=(f"raw-{request_id}",), memory_ids=(f"m-{request_id}",))
class _Evidence:
def get(self, evidence_id: str):
return SimpleNamespace(evidence_id=evidence_id, source_id="cursor", user_id="local")
def _ordered_request(index: int, *, source: str = "cursor") -> WriteRequest:
return WriteRequest(
internal_session_id="sess-1",
batch_identity=BatchIdentity(
internal_session_id="sess-1", first_round=index + 1, last_round=index + 1
),
evidence_ids=[f"e-{index:03d}"],
idempotency_key=f"idem-{index:03d}",
source_id=source,
event_at=datetime(2026, 10, 1, tzinfo=UTC) + timedelta(minutes=index),
)
def _jobs(path) -> list[tuple[str, str, int, int, str | None]]:
with sqlite3.connect(path) as database:
return database.execute(
"SELECT idempotency_key,state,sdk_attempt,retries,last_error "
"FROM memory_write_jobs ORDER BY queue_seq"
).fetchall()
def test_an_outage_with_200_queued_jobs_ends_with_every_job_written_once_in_order(
tmp_path,
) -> None:
"""The provider refuses every request for five minutes, then recovers.
No job is lost, skipped, written twice or left `unknown`; the order is
the order of the conversation; the provider sees a handful of probes
during the outage, not one request per job.
"""
clock = _Clock()
backoff = ModelBackoff(clock=clock)
calls: list[tuple[float, int]] = []
def provider(_request: httpx.Request) -> httpx.Response:
status = 401 if clock.now < 300.0 else 200
calls.append((clock.now, status))
return httpx.Response(status)
sdk = _FakeSdk(
BackedOffModelTransport(backoff, profile_id="p:1", inner=httpx.MockTransport(provider))
)
path = tmp_path / "one-memory.sqlite3"
queue = DurableWriteQueue(path)
for index in range(200):
queue.submit(_ordered_request(index))
drain = DurableWriteDrain(
queue,
execute=_never_execute,
owner="outage",
model_claim_allowance=backoff.allowance,
)
async def tick() -> None:
await asyncio.gather(
*(
execute_job(
job_id,
queue=queue,
supervisor=sdk,
evidence=_Evidence(),
owner="outage",
model_failures=backoff.attempt,
)
for job_id in drain.claim(limit=10)
)
)
for second in range(800):
clock.now = float(second)
asyncio.run(tick())
jobs = _jobs(path)
assert [state for _, state, *_ in jobs] == ["completed"] * 200
assert sdk.written == [f"e-{index:03d}" for index in range(200)]
outage = [at for at, status in calls if status == 401]
# One request at 0 s, then one probe per pause: 5, 15, 35, 75, 155 s.
assert outage == [0.0, 5.0, 15.0, 35.0, 75.0, 155.0]
# The first job was tried seven times; each try had its own SDK id, so
# the recovered provider was really called, not a replayed failure.
assert jobs[0][2:4] == (6, 0)
assert sdk.request_ids[:3] == ["idem-000", "idem-000#r1", "idem-000#r2"]
assert all(retries == 0 for *_, retries, _ in jobs)
def test_a_failure_of_the_job_itself_is_retried_three_times_then_skipped(tmp_path) -> None:
path = tmp_path / "one-memory.sqlite3"
queue = DurableWriteQueue(path)
first = queue.submit(_ordered_request(0))
second = queue.submit(_ordered_request(1))
sdk = _FakeSdk()
drain = DurableWriteDrain(queue, execute=_never_execute, owner="skip")
now = datetime.now(UTC)
def run(at: datetime) -> tuple[str, ...]:
claimed = drain.claim(limit=1, now=at)
for job_id in claimed:
asyncio.run(
execute_job(
job_id,
queue=queue,
supervisor=sdk,
evidence=_Evidence(),
owner="skip",
model_failures=ModelBackoff().attempt,
)
)
return claimed
sdk.fail_with = "model_content_json_parse_failed"
assert run(now) == (first.job_id,)
# Each retry waits longer: 30 s, 60 s, 120 s. Until then the claim skips
# the job, and the job after it in the conversation waits behind it.
assert run(now + timedelta(seconds=20)) == ()
assert run(now + timedelta(seconds=40)) == (first.job_id,)
assert run(now + timedelta(seconds=150)) == (first.job_id,)
assert run(now + timedelta(seconds=400)) == (first.job_id,)
assert _jobs(path)[0][1:] == ("failed", 3, 3, "model_content_json_parse_failed")
assert queue.get("idem-000").error_code == "model_content_json_parse_failed"
sdk.fail_with = None
assert run(now + timedelta(seconds=401)) == (second.job_id,)
assert _jobs(path)[1][1] == "completed"
assert sdk.request_ids == [
"idem-000",
"idem-000#r1",
"idem-000#r2",
"idem-000#r3",
"idem-001",
]
def test_a_write_interrupted_mid_call_is_rerun_under_a_new_request_id(tmp_path) -> None:
path = tmp_path / "one-memory.sqlite3"
queue = DurableWriteQueue(path)
record = queue.submit(_ordered_request(0))
drain = DurableWriteDrain(queue, execute=_never_execute, owner="crashed")
assert drain.claim(limit=1) == (record.job_id,)
assert queue.mark_invoked(record.job_id, owner="crashed")
# The process stops; on restart the lease has lapsed.
with sqlite3.connect(path) as database:
database.execute(
"UPDATE memory_write_jobs SET lease_expires_at=? WHERE job_id=?",
("2000-01-01T00:00:00+00:00", record.job_id),
)
assert queue.reclaim_expired() == 1
assert queue.get("idem-000").state == "unknown"
# Still running on its thread in this process: left alone.
assert queue.recover_unknown(in_flight=lambda request_id: request_id == "idem-000") == 0
assert queue.recover_unknown() == 1
assert _jobs(path)[0][1:] == ("pending", 1, 1, "durable_write_interrupted")
assert queue.recover_unknown() == 0
sdk = _FakeSdk()
later = datetime.now(UTC) + timedelta(seconds=31)
rerun = DurableWriteDrain(queue, execute=_never_execute, owner="restarted")
assert rerun.claim(limit=1, now=later) == (record.job_id,)
asyncio.run(
execute_job(
record.job_id, queue=queue, supervisor=sdk, evidence=_Evidence(), owner="restarted"
)
)
assert sdk.request_ids == ["idem-000#r1"]
assert queue.get("idem-000").state == "completed"
def test_a_write_that_did_land_is_completed_from_its_receipt_not_rerun(tmp_path) -> None:
path = tmp_path / "one-memory.sqlite3"
queue = DurableWriteQueue(path)
record = queue.submit(_ordered_request(0))
with sqlite3.connect(path) as database:
database.execute(
"UPDATE memory_write_jobs SET state='unknown',hy_invoked=1,"
"last_error='durable_work_ceiling_exceeded' WHERE job_id=?",
(record.job_id,),
)
sdk = _FakeSdk()
sdk.outcomes["idem-000"] = "succeeded"
assert queue.recover_unknown() == 1
drain = DurableWriteDrain(queue, execute=_never_execute, owner="landed")
assert drain.claim(limit=1, now=datetime.now(UTC) + timedelta(seconds=31)) == (record.job_id,)
asyncio.run(
execute_job(
record.job_id, queue=queue, supervisor=sdk, evidence=_Evidence(), owner="landed"
)
)
assert sdk.request_ids == []
completed = queue.get("idem-000")
assert completed.state == "completed"
assert completed.sdk_memory_ids == ("m-idem-000",)
def test_unknown_jobs_from_before_the_upgrade_are_handled_once(tmp_path) -> None:
from one_memory.memory_tasks import NON_RETRYABLE_POST_WRITE_CODES
path = tmp_path / "one-memory.sqlite3"
queue = DurableWriteQueue(path)
plain = queue.submit(_ordered_request(0, source="a"))
verified = queue.submit(_ordered_request(1, source="b"))
looping = queue.submit(_ordered_request(2, source="c"))
with sqlite3.connect(path) as database:
for job, code, retries in (
(plain, "hy_sdk_write_failed", 0),
(verified, NON_RETRYABLE_POST_WRITE_CODES[0], 0),
(looping, "durable_write_interrupted", 3),
):
database.execute(
"UPDATE memory_write_jobs SET state='unknown',hy_invoked=1,attempts=1,"
"last_error=?,retries=? WHERE job_id=?",
(code, retries, job.job_id),
)
assert queue.recover_unknown() == 3
assert queue.recover_unknown() == 0
# Reopening the queue (a restart) changes nothing either.
assert DurableWriteQueue(path).recover_unknown() == 0
states = {key: (state, attempt) for key, state, attempt, _, _ in _jobs(path)}
assert states == {
"idem-000": ("pending", 1),
# The write landed against an authority Core could not verify.
"idem-001": ("failed", 0),
# A crash loop ends instead of spinning.
"idem-002": ("failed", 0),
}
def test_concurrent_writes_are_all_charged_with_a_provider_failure() -> None:
"""The breaker cannot tell whose request failed, so every write in flight waits.
A write the provider did fail must never spend its retries on the outage;
a write that failed beside it for its own reason waits for the provider
once, and is retried with the rest.
"""
backoff = ModelBackoff(clock=_Clock())
with backoff.attempt() as first, backoff.attempt() as second:
backoff.record_failure("model_server_error", "p:1", "chat")
with backoff.attempt() as later:
pass
assert (first.failure, second.failure, later.failure) == (
"model_server_error",
"model_server_error",
None,
)
def test_the_manual_retry_endpoint_is_gone(tmp_path) -> None:
async def scenario() -> int:
app = create_app(
Settings(
data_dir=tmp_path / "data",
source_home=tmp_path,
api_token=SecretStr("local-token"),
),
secret_store=InMemorySecretStore(),
)
async with (
app.router.lifespan_context(app),
AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
headers={"Authorization": "Bearer local-token"},
) as client,
):
response = await client.post(
"/api/v1/memory-processing/retry-unknown", json={"confirm": True}
)
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
@@ -259,3 +259,52 @@ def test_recall_route_falls_back_and_keeps_the_raw_grant(tmp_path, monkeypatch):
# A host granted recall alone may not read original conversations.
assert recall_only["mode"] == "raw" and recall_only["hits"] == []
assert len(searched) == 1
def test_the_total_recall_budget_stays_under_the_brokers_wait():
from one_memory.recall_fallback import recall_total_timeout_seconds
assert recall_total_timeout_seconds(800) == pytest.approx(3.8)
# The broker gives Core 15 seconds; Core answers before it gives up.
assert recall_total_timeout_seconds(60_000) == 12.0
@pytest.mark.fresh_sdk
def test_a_recall_that_cannot_finish_in_time_is_a_clear_timeout(tmp_path, monkeypatch):
"""A recall stuck behind the SDK answers 504 `recall_timeout`, not nothing (#231)."""
from one_memory import recall_fallback
async def stuck(*_args, **_kwargs):
await asyncio.Event().wait()
monkeypatch.setattr(recall_fallback, "recall_with_fallback", stuck)
monkeypatch.setattr(recall_fallback, "recall_total_timeout_seconds", lambda _ms: 0.2)
async def scenario():
app = create_app(
Settings(
data_dir=tmp_path / "data",
source_home=tmp_path,
api_token=SecretStr("recall-test"),
imports_enabled=False,
automatic_memory_processing=False,
live_sync_enabled=False,
),
secret_store=InMemorySecretStore(),
)
async with (
app.router.lifespan_context(app),
AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
headers={"Authorization": "Bearer recall-test"},
) as client,
):
return await asyncio.wait_for(
client.post("/api/v1/recall", json={"query": "数据库"}), 5
)
response = asyncio.run(scenario())
assert response.status_code == 504
assert response.json()["detail"] == "recall_timeout"
@@ -1124,13 +1124,13 @@ def test_pre_upgrade_unknown_writes_are_settled_at_cutover_not_retried(store):
surviving = DurableWriteQueue(store.database_path, sdk_authority=True)
assert surviving.unknown_jobs() == ()
# Never requeued: they may already have written.
assert surviving.retry_unknown_confirmed(unknown[0]) == 0
assert surviving.recover_unknown() == 0
assert surviving.readmit_parked() == 0
assert PENDING not in {state for state, _, _ in rows.values()}
def test_retrying_unknown_writes_works_after_the_legacy_tables_are_dropped(store):
"""The retry button raised `no such table: memory_tasks` after an upgrade."""
def test_recovering_unknown_writes_works_after_the_legacy_tables_are_dropped(store):
"""The old retry button raised `no such table: memory_tasks` after an upgrade."""
from one_memory.memory_server.durable_queue import PENDING, UNKNOWN
from one_memory.target_schema import drop_legacy_tables
@@ -1162,14 +1162,10 @@ def test_retrying_unknown_writes_works_after_the_legacy_tables_are_dropped(store
surviving = DurableWriteQueue(store.database_path, sdk_authority=sdk_authority)
make_unknown("model_content_outcome_unknown", 1)
assert surviving.resolve_unknown_from_linked_output(identifier) is False
assert surviving.retry_unknown_confirmed(identifier) == 1
assert surviving.recover_unknown() == 1
assert surviving.get("after-upgrade").state == PENDING
make_unknown("hy_memory_timeout", 1)
since = datetime.now(UTC)
assert surviving.retry_timed_out_unknown_after_reconcile(older_than=since) == 1
make_unknown("model_content_outcome_unknown", 1)
assert surviving.retry_first_unknown_after_reconcile(older_than=since) == 1
with main_database(store.database_path).write() as database, database:
database.execute("UPDATE memory_write_jobs SET retries=0 WHERE job_id=?", (identifier,))
if sdk_authority:
# The SDK-authority queue never brings a dropped table back.
assert legacy_tables_present(store.database_path) == ()
@@ -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.11"
assert manifest["snapshot_sdk_version"] == "2.0.13"
paths = {item["path"]: item for item in manifest["files"]}
assert {
"management/one-memory.sqlite3",
+4 -4
View File
@@ -991,7 +991,7 @@ wheels = [
[[package]]
name = "hy-memory"
version = "2.0.11"
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/01/93/aec7d243259f6a315f38e8ebc21a9eea153a48fc13648fda149be7672788/hy_memory-2.0.11.tar.gz", hash = "sha256:96d6bd606d61052f127cd274aa6f8de8e7151d6250f1e89e4ef5b90e18ec7cb0", size = 1245738, upload-time = "2026-11-01T14:15:02.474Z" }
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/1a/85/1f5e6a13507a0cfcdce26f67c590df00d327a0f7d5c5b5483c3bd0fa2f85/hy_memory-2.0.11-py3-none-any.whl", hash = "sha256:c0173444a83c1197a49acaccb123f8286cea205b4838c1e83d106974f5bad259", size = 1315871, upload-time = "2026-11-01T14:14:58.764Z" },
{ 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.11" },
{ 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" },
+36 -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,7 @@ 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();
noModelWrites();
@@ -188,7 +222,7 @@ it("keeps an open model draft while switching settings groups", async () => {
"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",
+5
View File
@@ -25,6 +25,7 @@ import { useApplicationsLanguage } from "./applications-language";
import { CallHistory } from "./CallHistory";
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";
@@ -309,6 +310,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. */}
@@ -0,0 +1,238 @@
// @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";
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;
vi.clearAllMocks();
});
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,441 @@
import type {
EmbeddingSwitchCurrent,
EmbeddingSwitchJob,
EmbeddingSwitchPlan,
} from "@one-memory/contracts";
import { ChevronDown, CircleAlert, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { deleteRetiredEmbeddingSpace } from "./api";
import { R6Button } from "./R6Controls";
import { useEmbeddingSwitch } from "./use-embedding-switch";
import "./embedding-switch.css";
function count(value: number | null | undefined) {
return new Intl.NumberFormat().format(value ?? 0);
}
function duration(seconds: number | null) {
if (seconds === null) return "暂时无法估算";
if (seconds < 60) return `约 ${Math.max(1, Math.round(seconds))} 秒`;
return `约 ${Math.max(1, Math.round(seconds / 60))} 分钟`;
}
function size(bytes: number) {
if (bytes < 1024 * 1024) return `${Math.ceil(bytes / 1024)} KB`;
return `${Math.ceil(bytes / 1024 / 1024)} MB`;
}
const blockerCopy: Record<string, string> = {
embedding_probe_required: "请先测试新的搜索模型连接。",
embedding_dimensions_unknown:
"还无法确认新搜索模型的索引规格,请重新测试连接。",
embedding_switch_requires_migration:
"旧版本数据仍在升级,请等待升级完成后再更换。",
sdk_reembed_unavailable: "当前安装版本还不能安全更新记忆索引。",
insufficient_disk_space: "本机剩余空间不足,无法安全建立新索引。",
embedding_switch_running: "另一次索引更新正在进行。",
memory_lifecycle_busy: "记忆正在整合,请等待完成后再更换搜索模型。",
};
const failureCopy: Record<string, string> = {
writes_did_not_drain: "等待正在进行的记忆整理结束超时。",
embedding_service_failed: "搜索模型服务连续请求失败。",
embedding_dimensions_mismatch: "搜索服务返回的索引规格与测试结果不一致。",
embedding_verification_failed: "新的记忆索引没有通过完整性校验。",
target_profile_changed: "更新期间搜索模型配置发生了变化。",
embedding_switch_cancelled: "索引更新已取消。",
model_secret_unavailable: "无法读取搜索服务的 API Key。",
secure_storage_unavailable: "系统安全存储暂时不可用。",
embedding_switch_clients_busy: "记忆服务一直被占用,暂时无法完成切换。",
};
export function EmbeddingSwitchConfirmDialog({
plan,
busy,
errorCode,
onCancel,
onStart,
}: {
plan: EmbeddingSwitchPlan;
busy: boolean;
errorCode: string | null;
onCancel(): void;
onStart(): void;
}) {
const panel = useRef<HTMLElement>(null);
const blockers = plan.blockers.map(
(code) => blockerCopy[code] ?? "当前状态暂时不允许更换搜索模型。",
);
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">更新记忆搜索索引?</h2>
<p>
不同搜索模型建立的索引不能混用。为了继续找到已有记忆,需要重新建立索引。
</p>
</div>
</header>
<dl className="embedding-switch-impact">
<div>
<dt>已有记忆</dt>
<dd>
{count(plan.total_vectors)} 条
{plan.users && plan.users > 1
? ` · ${count(plan.users)} 位用户`
: ""}
</dd>
</div>
<div>
<dt>预计耗时</dt>
<dd>{duration(plan.estimate_s)}</dd>
</div>
<div>
<dt>远程请求</dt>
<dd>
{plan.target_local
? "不会产生"
: plan.requests === null
? "按服务商计费"
: `约 ${count(plan.requests)} 次`}
</dd>
</div>
</dl>
<div className="embedding-switch-safety">
<strong>更新期间仍可搜索记忆</strong>
<p>
MemHub
会继续使用原来的搜索模型。新对话仍会保存,更新完成后再整理成记忆。
</p>
<strong>失败不会影响已有数据</strong>
<p>
只有全部更新并校验通过后才会切换;失败或取消时仍使用原来的模型。
</p>
</div>
<details className="embedding-switch-details">
<summary>
<ChevronDown size={14} aria-hidden="true" />
查看技术详情
</summary>
<dl>
<div>
<dt>当前搜索模型</dt>
<dd>{plan.source.model ?? "旧版本索引"}</dd>
</div>
<div>
<dt>新的搜索模型</dt>
<dd>{plan.target.model ?? "待确认"}</dd>
</div>
<div>
<dt>索引维度</dt>
<dd>
{plan.source.dims ?? "未知"} → {plan.target.dims ?? "未知"}
</dd>
</div>
<div>
<dt>额外磁盘空间</dt>
<dd>{size(plan.disk_bytes_needed)}</dd>
</div>
<div>
<dt>旧索引保留</dt>
<dd>{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"
? "记忆状态发生了变化,请重新检查更换影响。"
: "索引更新暂时无法开始,请稍后重试。"}
</span>
</div>
) : null}
<footer>
<R6Button disabled={busy} onClick={onCancel}>
暂不更换
</R6Button>
<R6Button
variant="primary"
pending={busy}
disabled={blockers.length > 0}
onClick={onStart}
>
开始更新索引
</R6Button>
</footer>
</section>
</div>
);
}
function jobTitle(job: EmbeddingSwitchJob) {
if (job.kind === "merge") return "正在整理旧版本留下的搜索索引";
if (job.state === "preparing") return "正在准备更新记忆索引";
if (job.state === "reembedding") return "正在更新记忆索引";
if (job.state === "verifying") return "正在校验新的记忆索引";
if (job.state === "committing") return "正在启用新的搜索模型";
if (job.state === "failed") return "更新没有完成,正在恢复原设置";
return "记忆索引更新";
}
function canCancel(job: EmbeddingSwitchJob) {
return ["preparing", "reembedding", "verifying"].includes(job.state);
}
function ActiveSwitchBanner({
current,
onCancel,
cancelling,
}: {
current: EmbeddingSwitchCurrent;
onCancel(): void;
cancelling: boolean;
}) {
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(job) : "正在检查本机的记忆搜索索引"}</strong>
<span>
{job?.state === "failed"
? "原来的搜索模型仍在使用,记忆没有丢失。"
: job
? `${Math.round(percent)}%${
job.progress.eta_s === null
? ""
: ` · ${duration(job.progress.eta_s)}`
}`
: "完成前暂不整理新记忆,搜索仍可使用。"}
</span>
</div>
{job ? (
<div
className="embedding-switch-progress"
role="progressbar"
aria-label="记忆索引更新进度"
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}>
取消更新
</R6Button>
) : null}
</aside>
);
}
export function EmbeddingSwitchGlobalStatus({
onConfigureSearch,
onDiagnostics,
}: {
onConfigureSearch(): void;
onDiagnostics(): void;
}) {
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>索引状态暂时无法读取</strong>
<span>当前显示可能不是最新状态,可以继续使用已有记忆。</span>
</div>
<R6Button variant="quiet" onClick={() => void controller.refresh()}>
重新读取
</R6Button>
</aside>
) : null}
{controller.current &&
["failed", "unavailable"].includes(controller.current.scan_state) ? (
<aside className="embedding-switch-outcome is-error" role="alert">
<div>
<strong>旧版本搜索索引检查未完成</strong>
<span>
{controller.current.writes_paused
? "新的记忆整理暂时暂停,已有记忆仍可搜索。"
: "记忆整理已经恢复;可以继续使用,诊断中保留了失败原因。"}
</span>
</div>
<R6Button variant="quiet" onClick={onDiagnostics}>
查看诊断
</R6Button>
</aside>
) : null}
{recent?.state === "done" ? (
<aside
className="embedding-switch-outcome"
role="status"
aria-live="polite"
>
<div>
<strong>搜索模型已更换</strong>
<span>暂停期间保存的新对话正在整理成记忆。</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);
});
}}
>
清理旧索引
</R6Button>
) : null}
<R6Button
variant="quiet"
aria-label="关闭完成提示"
onClick={() => setRecent(null)}
>
<X size={16} aria-hidden="true" />
</R6Button>
</div>
</aside>
) : null}
{cleanupError ? (
<p className="embedding-switch-cleanup-error" role="alert">
旧索引暂时无法清理,MemHub 会在保留期结束后自动重试。
</p>
) : null}
{recent?.state === "rolled_back" ? (
<aside
className="embedding-switch-outcome is-error"
role="alert"
aria-live="assertive"
>
<div>
<strong>索引更新没有完成</strong>
<span>
{failureCopy[recent.error_code ?? ""]
? `${failureCopy[recent.error_code ?? ""]} 仍在使用原来的搜索模型,记忆没有丢失。`
: "仍在使用原来的搜索模型,记忆没有丢失。"}
</span>
</div>
<div className="embedding-switch-outcome-actions">
<R6Button variant="quiet" onClick={onDiagnostics}>
查看诊断
</R6Button>
<R6Button variant="quiet" onClick={onConfigureSearch}>
检查连接
</R6Button>
<R6Button
variant="quiet"
aria-label="关闭失败提示"
onClick={() => setRecent(null)}
>
<X size={16} aria-hidden="true" />
</R6Button>
</div>
</aside>
) : null}
</>
);
}
+84 -37
View File
@@ -8,6 +8,8 @@ import type {
MemoryProcessingSummary,
SdkAskMemory,
SdkMemoryScene,
SdkRawContextItem,
SdkRawRecord,
SourceResponse,
} from "@one-memory/contracts";
import type { GuiLocale } from "@one-memory/i18n";
@@ -30,6 +32,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 +51,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";
@@ -89,6 +93,8 @@ type Props = {
id?: string;
arrival?: MemoryArrival;
onViewHistory?(): void;
contentKind?: "memory" | "raw";
rawRecord?: SdkRawRecord | SdkRawContextItem | null;
libraryView?: {
query: string;
source: string;
@@ -171,6 +177,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,
@@ -699,6 +707,7 @@ export function MemoryMap(props: Props) {
: "";
if (
props.simplified &&
!raw &&
(!library ||
(!library.loading &&
!library.failed &&
@@ -860,11 +869,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 +984,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 +1027,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 +1046,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 +1060,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 +1170,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 +1222,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 +1352,15 @@ 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
? text("关联节点")
: text("记忆详情")}
</h2>
{(activeId || visibleGroupNode) && (
<button
@@ -1339,7 +1380,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 +1399,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 +1468,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}
@@ -0,0 +1,241 @@
// @vitest-environment jsdom
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import {
getEvolveOverview,
getEvolveSchedule,
getMemoryMode,
getMemoryProcessing,
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(),
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);
}
async function chooseMode(
user: ReturnType<typeof userEvent.setup>,
name: RegExp,
) {
const picker = await screen.findByRole("combobox", { name: "当前模式" });
await user.click(picker);
await user.click(screen.getByRole("option", { name }));
return picker;
}
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
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(setMemoryMode).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "切换模式" }));
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("lite"));
});
it("traps modal focus, closes on Escape and restores the mode-card focus", async () => {
prepare("pro");
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));
await user.keyboard("{Shift>}{Tab}{/Shift}");
expect(document.activeElement).toBe(
screen.getByRole("button", { name: "切换模式" }),
);
await user.keyboard("{Escape}");
expect(screen.queryByRole("dialog")).toBeNull();
expect(document.activeElement).toBe(lite);
});
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: "切换模式" }));
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");
const configure = vi.fn();
const user = userEvent.setup();
render(
<MemoryModeSettings hasActiveModel={false} onConfigureModel={configure} />,
);
await chooseMode(user, /智能记忆(pro)/);
expect(configure).toHaveBeenCalledTimes(1);
expect(setMemoryMode).not.toHaveBeenCalled();
expect(
screen.getByText("使用 pro 或 ultra 前需要先配置并启用生成模型。"),
).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.getByRole("button", { name: "立即整合" })).toBeTruthy();
});
it("ignores an older refresh that finishes after a newer mode read", async () => {
prepare("pro");
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const picker = await screen.findByRole("combobox", { name: "当前模式" });
let resolveOld!: (value: ReturnType<typeof state>) => void;
vi.mocked(getMemoryMode)
.mockReturnValueOnce(
new Promise((resolve) => {
resolveOld = resolve;
}),
)
.mockResolvedValueOnce(state("lite"));
const refresh = screen.getByRole("button", { name: "刷新记忆模式" });
await user.click(refresh);
await user.click(refresh);
await waitFor(() => expect((picker as HTMLButtonElement).value).toBe("lite"));
resolveOld(state("pro"));
await waitFor(() => expect((picker as HTMLButtonElement).value).toBe("lite"));
});
it("does not let an older refresh undo a saved schedule change", async () => {
prepare("ultra");
vi.mocked(setEvolveSchedule).mockResolvedValue({
enabled: false,
time: "03:00",
});
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const toggle = await screen.findByRole("switch", {
name: "每天自动整合",
});
let resolveOld!: (value: { enabled: true; time: string }) => void;
vi.mocked(getEvolveSchedule).mockReturnValueOnce(
new Promise((resolve) => {
resolveOld = resolve;
}),
);
await user.click(screen.getByRole("button", { name: "刷新记忆模式" }));
await user.click(toggle);
await waitFor(() =>
expect(setEvolveSchedule).toHaveBeenCalledWith({ enabled: false }),
);
resolveOld({ enabled: true, time: "03:00" });
await waitFor(() =>
expect(toggle.getAttribute("aria-checked")).toBe("false"),
);
});
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("combobox", {
name: "当前模式",
})) as HTMLButtonElement,
).toHaveProperty("disabled", false);
});
it("keeps lite available while model status is unknown", async () => {
prepare("pro");
const user = userEvent.setup();
render(
<MemoryModeSettings
hasActiveModel={false}
modelStateKnown={false}
onConfigureModel={vi.fn()}
/>,
);
await user.click(await screen.findByRole("combobox", { name: "当前模式" }));
expect(
screen.getByRole("option", { name: /基础记忆(lite)/ }),
).toHaveProperty("disabled", false);
expect(
screen.getByRole("option", { name: /智能记忆(pro)/ }),
).toHaveProperty("disabled", true);
expect(
screen.getByRole("option", { name: /深度记忆(ultra)/ }),
).toHaveProperty("disabled", true);
});
+420
View File
@@ -0,0 +1,420 @@
import type {
EvolveOverview,
EvolveRunReceipt,
EvolveSchedule,
MemoryModeChange,
MemoryModeState,
MemoryPipelineMode,
} from "@one-memory/contracts";
import { RefreshCw } from "lucide-react";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import {
apiErrorCode,
getEvolveOverview,
getEvolveSchedule,
getMemoryMode,
getMemoryProcessing,
setEvolveSchedule,
setMemoryMode,
startEvolve,
} from "./api";
import { OptionMenu } from "./OptionMenu";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button, R6Switch } from "./R6Controls";
const MODE_COPY: Record<
MemoryPipelineMode,
{ title: string; description: string }
> = {
lite: {
title: "基础记忆(lite)",
description: "不调用生成模型。保存并检索原始对话,不再生成高级记忆。",
},
pro: {
title: "智能记忆(pro)",
description: "使用当前模型从新对话中提取事实、偏好和计划。",
},
ultra: {
title: "深度记忆(ultra)",
description: "在智能记忆之上使用知识图谱,并默认每天 03:00 自动整合记忆。",
},
};
const EFFECT_COPY: Record<string, string> = {
earlier_raw_not_extracted:
"lite 下已经处理完成的原始对话不会补提取;排队中和之后的新对话会生成高级记忆。",
lite_search_stops: "原始对话检索会停止,改为使用高级记忆。",
higher_mode_memories_hidden:
"以前生成的高级记忆会继续保留,但在 lite 下不会显示或参与记忆召回。",
evolve_stopped: "每天自动整合会停止,已经生成的数据不会删除。",
existing_memories_added_to_graph:
"已有高级记忆会在后台加入知识图谱,期间可以继续使用 MemHub。",
graph_backfill_already_running: "知识图谱仍在补建,不会重复启动。",
};
function plannedEffects(
current: MemoryPipelineMode,
target: MemoryPipelineMode,
): string[] {
if (current === target) return [];
const effects: string[] = [];
if (current === "lite" && target !== "lite")
effects.push("earlier_raw_not_extracted", "lite_search_stops");
if (target === "lite") effects.push("higher_mode_memories_hidden");
if (current === "ultra" && target !== "ultra") effects.push("evolve_stopped");
if (target === "ultra") effects.push("existing_memories_added_to_graph");
return effects;
}
export function MemoryModeSettings({
hasActiveModel,
modelStateKnown = true,
onConfigureModel,
onModeChange,
}: {
hasActiveModel: boolean;
modelStateKnown?: boolean;
onConfigureModel(): void;
onModeChange?(mode: MemoryPipelineMode): void;
}) {
const { text } = 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 [busy, setBusy] = useState(false);
const [maintenance, setMaintenance] = useState(false);
const [error, setError] = useState("");
const [run, setRun] = useState<EvolveRunReceipt | null>(null);
const refreshVersion = useRef(0);
const dialog = useRef<HTMLElement>(null);
const dialogOpener = useRef<HTMLElement | null>(null);
const busyRef = useRef(false);
busyRef.current = busy;
useEffect(() => {
if (!pendingMode || !dialog.current) return;
const panel = dialog.current;
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();
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);
if (dialogOpener.current?.isConnected) dialogOpener.current.focus();
};
}, [pendingMode]);
useEffect(() => {
if (pendingMode && busy) dialog.current?.focus();
}, [busy, pendingMode]);
const refresh = useCallback(async () => {
const version = ++refreshVersion.current;
const [modeResult, scheduleResult, overviewResult, processingResult] =
await Promise.allSettled([
getMemoryMode(),
getEvolveSchedule(),
getEvolveOverview(),
getMemoryProcessing(),
]);
if (version !== refreshVersion.current) return;
if (modeResult.status === "fulfilled") {
setState(modeResult.value);
onModeChange?.(modeResult.value.mode);
setError("");
} else {
setError("记忆模式状态暂时无法读取,请重试。");
}
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";
function choose(mode: MemoryPipelineMode) {
if (!state || mode === state.mode || busy || lifecycleBusy) return;
if (mode !== "lite" && !hasActiveModel) {
setError("使用 pro 或 ultra 前需要先配置并启用生成模型。");
onConfigureModel();
return;
}
setError("");
dialogOpener.current = document.getElementById(`${id}-select`);
setPendingMode(mode);
}
async function commitMode() {
if (!pendingMode) return;
refreshVersion.current++;
setBusy(true);
setError("");
try {
const changed: MemoryModeChange = await setMemoryMode(pendingMode);
setState(changed);
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>
<R6Button
variant="quiet"
aria-label={text("刷新记忆模式")}
title={text("刷新记忆模式")}
disabled={busy}
onClick={() => void refresh()}
>
<RefreshCw size={16} aria-hidden="true" />
</R6Button>
</div>
<div
className="r6-language-setting-row r6-mode-setting-row"
aria-busy={state === null}
>
<label id={`${id}-label`} htmlFor={`${id}-select`}>
{text("当前模式")}
</label>
<OptionMenu
id={`${id}-select`}
labelledBy={`${id}-label`}
value={state?.mode ?? ""}
disabled={!state || busy || lifecycleBusy}
options={(["lite", "pro", "ultra"] as const).map((mode) => ({
value: mode,
label: text(MODE_COPY[mode].title),
description: text(MODE_COPY[mode].description),
disabled: mode !== "lite" && !modelStateKnown,
}))}
className="r6-language-option-menu r6-mode-option-menu"
panelClassName="r6-language-option-menu-panel--settings r6-mode-option-menu-panel"
onChange={(value) => {
if (value === "lite" || value === "pro" || value === "ultra")
choose(value);
}}
/>
</div>
{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 ? (
<p className="r6-settings-error" role="alert">
{text(error)}
</p>
) : null}
{state?.mode === "ultra" && schedule ? (
<div className="r6-evolve-settings">
<div className="r6-setting-row">
<div>
<h3>{text("每天自动整合")}</h3>
<p>
{text(
"每天 {time} 自动运行 evolve,设备休眠错过后会补跑。",
).replace("{time}", schedule.time)}
</p>
</div>
<R6Switch
label={text("每天自动整合")}
checked={schedule.enabled}
disabled={busy || maintenance}
onCheckedChange={(enabled) => void changeSchedule({ enabled })}
/>
</div>
<div className="r6-setting-row">
<div>
<h3>{text("自动整合时间")}</h3>
<p>{text("使用这台设备的本地时间。")}</p>
</div>
<input
type="time"
value={schedule.time}
disabled={busy || maintenance}
aria-label={text("自动整合时间")}
onChange={(event) =>
void changeSchedule({ time: event.target.value })
}
/>
</div>
<div className="r6-setting-row">
<div>
<h3>{text("立即整合记忆")}</h3>
<p>
{text(
run?.state === "running"
? "正在整合记忆…"
: last?.last_run_at
? text("上次整合:{time}").replace(
"{time}",
new Date(last.last_run_at).toLocaleString(),
)
: "尚未整合",
)}
</p>
</div>
<R6Button
variant="primary"
pending={run?.state === "running"}
disabled={busy || lifecycleBusy}
onClick={() => void evolveNow()}
>
{text("立即整合")}
</R6Button>
</div>
</div>
) : null}
{pendingMode ? (
<div className="r6-settings-dialog-layer" role="presentation">
<section
ref={dialog}
className="r6-settings-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="memory-mode-confirm-title"
tabIndex={-1}
>
<h2 id="memory-mode-confirm-title">
{text("切换到 {mode}?").replace("{mode}", pendingMode)}
</h2>
<ul>
{plannedEffects(state?.mode ?? "pro", pendingMode).map(
(effect) => (
<li key={effect}>{text(EFFECT_COPY[effect])}</li>
),
)}
<li>{text("原始对话在所有模式下都会保留。")}</li>
</ul>
<footer>
<R6Button disabled={busy} onClick={() => setPendingMode(null)}>
{text("取消")}
</R6Button>
<R6Button
variant="primary"
pending={busy}
onClick={() => void commitMode()}
>
{text("切换模式")}
</R6Button>
</footer>
</section>
</div>
) : null}
</section>
);
}
+126
View File
@@ -74,6 +74,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,6 +84,8 @@ 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", () => ({
@@ -194,8 +198,130 @@ function setup() {
completed: 1,
});
reads.profiles.mockResolvedValue([]);
reads.raw.mockResolvedValue({
items: [],
total: 0,
limit: 100,
offset: 0,
});
reads.rawSearch.mockResolvedValue([]);
}
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();
expect(screen.queryByRole("button", { name: "补充记忆" })).toBeNull();
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(screen.getByText(/当前为 lite/)).toBeTruthy();
});
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 screen.findByText(/当前为 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()} />);
+99 -22
View File
@@ -4,7 +4,6 @@ import type { GovernedMemory, MemoryDetail } from "@one-memory/contracts";
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
@@ -75,7 +74,6 @@ const {
askMemoriesStream,
setExtractionWindow,
manualExtract,
retryUnknownMemoryProcessing,
getGraphWidth,
decideGraphWidth,
getModelProfiles,
@@ -89,7 +87,6 @@ const {
askMemoriesStream: vi.fn(),
setExtractionWindow: vi.fn(),
manualExtract: vi.fn(),
retryUnknownMemoryProcessing: vi.fn(),
getGraphWidth: vi.fn(),
decideGraphWidth: vi.fn(),
getModelProfiles: vi.fn(),
@@ -107,7 +104,6 @@ vi.mock("./api", () => ({
askMemoriesStream,
setExtractionWindow,
manualExtract,
retryUnknownMemoryProcessing,
getGraphWidth,
decideGraphWidth,
getModelProfiles,
@@ -159,7 +155,6 @@ describe("MemorySpace", () => {
in_flight_count: 0,
seconds_per_completion: null,
});
retryUnknownMemoryProcessing.mockResolvedValue({ requeued: 0 });
getGraphWidth.mockResolvedValue({ mismatch: null, memory_count: 1 });
decideGraphWidth.mockResolvedValue({
archived_path: null,
@@ -394,11 +389,9 @@ describe("MemorySpace", () => {
).toBeTruthy();
});
it("shows unknown results once and only retries after confirmation", async () => {
const confirm = vi
.spyOn(window, "confirm")
.mockReturnValueOnce(false)
.mockReturnValueOnce(true);
it("leaves unknown outcomes to Core: no manual retry, no alarm", async () => {
// An unknown outcome is a transient "checking" state Core settles by
// itself within seconds; there is nothing for the reader to do.
getMemoryProcessing.mockResolvedValue({
pending: 0,
fresh_pending: 0,
@@ -420,20 +413,104 @@ describe("MemorySpace", () => {
in_flight_count: 0,
seconds_per_completion: 20,
});
retryUnknownMemoryProcessing.mockResolvedValue({ requeued: 17 });
render(<MemorySpace />);
expect(
await screen.findByText("有 17 段对话的整理结果不确定"),
).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "重新整理" }));
expect(retryUnknownMemoryProcessing).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "重新整理" }));
await waitFor(() =>
expect(retryUnknownMemoryProcessing).toHaveBeenCalledOnce(),
);
expect(confirm).toHaveBeenCalledTimes(2);
confirm.mockRestore();
expect(await screen.findByText("整理状态")).toBeTruthy();
expect(screen.queryByText("整理需要处理")).toBeNull();
expect(screen.queryByText(/整理结果不确定/)).toBeNull();
expect(screen.queryByRole("button", { name: "重新整理" })).toBeNull();
});
describe("模型熔断提示", () => {
function backoff(
error: string | null,
pending = 4,
): Record<string, unknown> {
return {
pending,
running: 0,
fresh_pending: pending,
backlog_pending: 0,
recent_completions: [],
waiting_for_model: 0,
completed: 1,
failed: 0,
blocked_stages: [],
outside_window_pending: 0,
extraction_window_preset: "3m",
manual_extract_batches_planned: 0,
manual_extract_batches_current: 0,
active_queue: 0,
in_flight_count: 0,
seconds_per_completion: null,
suspended: 0,
llm_backoff_until: error ? "2026-11-02T10:00:00+00:00" : null,
llm_backoff_error: error,
};
}
it("names the mapped reason and the queued count, and links to the model", async () => {
const onConfigureModel = vi.fn();
getMemoryProcessing.mockResolvedValue(backoff("model_auth_failed"));
render(
<MemorySpace
onConnect={() => {}}
onConfigureModel={onConfigureModel}
/>,
);
const banner = await screen.findByRole("status", {
name: "模型暂停处理",
});
expect(banner.textContent).toContain(
"模型配置有问题(API Key 无效或没有权限),记忆暂停处理,已有 4 条在排队",
);
await userEvent.click(
within(banner).getByRole("button", { name: "配置整理模型" }),
);
expect(onConfigureModel).toHaveBeenCalledTimes(1);
});
it("reads an unrecognised code as a plain connection failure", async () => {
getMemoryProcessing.mockResolvedValue(backoff("model_something_new", 1));
render(<MemorySpace onConnect={() => {}} onConfigureModel={() => {}} />);
const banner = await screen.findByRole("status", {
name: "模型暂停处理",
});
expect(banner.textContent).toContain("(连接模型服务失败)");
expect(banner.textContent).toContain("已有 1 条在排队");
});
it("shows nothing while the breaker is closed", async () => {
getMemoryProcessing.mockResolvedValue(backoff(null));
render(<MemorySpace onConnect={() => {}} onConfigureModel={() => {}} />);
expect(await screen.findByText("正在整理记忆")).toBeTruthy();
expect(screen.queryByRole("status", { name: "模型暂停处理" })).toBeNull();
expect(screen.queryByText(/模型配置有问题/)).toBeNull();
});
it("goes away by itself once a refresh clears the pause", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
getMemoryProcessing.mockResolvedValue(backoff("model_timeout"));
render(<MemorySpace onConnect={() => {}} onConfigureModel={() => {}} />);
const banner = await screen.findByRole("status", {
name: "模型暂停处理",
});
expect(banner.textContent).toContain("模型响应超时");
getMemoryProcessing.mockResolvedValue(backoff(null));
await act(async () => {
await vi.advanceTimersByTimeAsync(3_500);
});
await waitFor(() =>
expect(
screen.queryByRole("status", { name: "模型暂停处理" }),
).toBeNull(),
);
});
});
it("splits the queue into new and catch-up rather than one total", async () => {
+289 -135
View File
@@ -36,8 +36,9 @@ import {
getSdkMemory,
getSdkMemoryGraph,
getSdkMemoryLibrary,
getSdkRaw,
manualExtract,
retryUnknownMemoryProcessing,
searchSdkRaw,
setExtractionWindow,
updateSdkMemory,
} from "./api";
@@ -46,7 +47,6 @@ import { useGuiLanguage } from "./gui-language";
import { MemoryDeleteDialog } from "./MemoryDeleteDialog";
import { MemoryMap } from "./MemoryMap";
import { subscribePoll } from "./poll-scheduler";
import { R6Button } from "./R6Controls";
import { SdkMemoryLibrary } from "./SdkMemoryLibrary";
import { SdkRawHistory } from "./SdkRawHistory";
import {
@@ -56,6 +56,12 @@ import {
sdkLibraryPage,
sdkNamespace,
} from "./sdk-memory-map";
import {
type RawMapRecord,
rawDetail,
rawLibraryPage,
rawSearchPage,
} from "./sdk-raw-map";
import { sourceNameOr, UNATTRIBUTED_SOURCE_ID } from "./source-label";
import { traceUiAction } from "./ui-trace";
import { isDefiniteRefusal, writeTraceStatus } from "./write-outcome";
@@ -65,6 +71,7 @@ import type { MemoryArrival } from "./memory-arrival-state";
import {
blockReasonLabel,
extractionWindowPresetLabel,
llmBackoffReasonLabel,
modelBlock,
splitBlockedStages,
taskKindLabel,
@@ -110,6 +117,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";
@@ -184,17 +195,31 @@ 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 sdkPage =
sdkLibrary.settledMode === pipelineMode ? sdkLibrary.page : null;
const sdkRequestedScope = JSON.stringify([
pipelineMode,
sdkLibraryQuery.trim(),
sdkLibrarySource,
]);
@@ -214,8 +239,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
@@ -225,7 +251,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) => {
@@ -234,42 +260,68 @@ 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(selectedRawRef.current.raw_id, selectedRawRef.current);
for (const item of items) next.set(item.raw_id, 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,
@@ -277,11 +329,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 {
@@ -306,6 +359,8 @@ export function MemorySpace({
sdkLibraryOffset,
sdkLibraryQuery,
sdkLibrarySource,
lite,
pipelineMode,
]);
const sdkFilterLabel = [
sdkLibrary.settledSource
@@ -338,9 +393,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.
@@ -409,7 +461,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.
}
@@ -439,8 +495,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(() => {
@@ -461,14 +538,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({
@@ -594,6 +680,22 @@ export function MemorySpace({
const epoch = ++detailEpoch.current;
setError(null);
setDetail(null);
setSelectedRaw(null);
if (lite) {
const record = rawRecords.current.get(memoryId);
if (!record) {
setDetailLoad({
memoryId,
status: "failed",
error: t("sdk.raw.loadFailed"),
});
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.
@@ -691,44 +793,15 @@ export function MemorySpace({
}
}
async function retryUnknown() {
if (
busy ||
!window.confirm(
t(
"memory:重新整理可能再次调用模型。只会重跑结果不确定的对话,是否继续?",
),
)
)
return;
setBusy(true);
setError(null);
const trace = traceUiAction("memory", "retry");
try {
const receipt = await retryUnknownMemoryProcessing(trace.traceId);
trace.done("succeeded", { count: receipt.requeued });
setProcessing(await getMemoryProcessing());
} catch (reason) {
trace.done(writeTraceStatus(reason), {
reason: isDefiniteRefusal(reason)
? "memory_unknown_retry_rejected"
: "memory_unknown_retry_result_unknown",
});
setError(message(reason, t));
} finally {
setBusy(false);
}
}
// What the reader owes attention, and what the pipeline is already handling
// on its own. The second half is counted, never listed: an internal guard
// that clears itself on the next pass is not something to act on. See
// `worthNamingBlock`.
// `worthNamingBlock`. An unknown outcome is in the second half too: Core
// settles it by itself within seconds.
const blocked = processing
? splitBlockedStages(processing.blocked_stages)
: { named: [], retrying: 0 };
const processingNeedsAttention =
blocked.named.length > 0 || (processing?.unknown ?? 0) > 0;
const processingNeedsAttention = blocked.named.length > 0;
return (
<section
@@ -763,40 +836,55 @@ export function MemorySpace({
</h1>
<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}
onPendingChange={setPendingAdd}
onSaved={() => {
// A finished addition is a new memory: re-read the page now rather
// than leave it for the next poll.
bumpSdkMap();
}}
onOpenMemory={(id) => {
if (!simplified) setMode("library");
void open(id);
}}
onConfigureModel={onConfigureModel}
/>
{!lite ? (
<MemoryAddComposer
suspended={!active}
onPendingChange={setPendingAdd}
onSaved={() => {
// A finished addition is a new memory: re-read the page now rather
// than leave it for the next poll.
bumpSdkMap();
}}
onOpenMemory={(id) => {
if (!simplified) setMode("library");
void open(id);
}}
onConfigureModel={onConfigureModel}
/>
) : null}
{!simplified && (
<div
className="memory-mode-tabs"
@@ -864,6 +952,19 @@ export function MemorySpace({
</div>
)}
</header>
{simplified && pipelineMode ? (
<p className="memory-mode-notice" role="status">
{lite
? t(
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。",
)
: pipelineMode === "ultra"
? t(
"memory:当前使用深度记忆。新对话会生成高级记忆,并由知识图谱和 evolve 继续整合。",
)
: t("memory:当前使用智能记忆。新对话会由模型整理为高级记忆。")}
</p>
) : null}
{processing && (!simplified || managementOpen) ? (
<details className="memory-processing-details">
<summary>
@@ -933,19 +1034,6 @@ export function MemorySpace({
</span>
) : null}
</p>
{(processing.unknown ?? 0) > 0 ? (
<div className="memory-processing-unknown" role="status">
<span>
{tn(
"memory:有 {count} 段对话的整理结果不确定",
processing.unknown ?? 0,
)}
</span>
<R6Button disabled={busy} onClick={() => void retryUnknown()}>
{t("memory:重新整理")}
</R6Button>
</div>
) : null}
<ExtractionWindowControls
processing={processing}
busy={busy}
@@ -963,12 +1051,20 @@ export function MemorySpace({
</details>
) : null}
<ModelPrompt
processing={processing}
hasMemories={(sdkLibrary.page?.total ?? 0) > 0}
onConfigureModel={onConfigureModel}
/>
{simplified && pendingAdd ? (
{!lite ? (
<>
<ModelBackoffBanner
processing={processing}
onConfigureModel={onConfigureModel}
/>
<ModelPrompt
processing={processing}
hasMemories={(sdkPage?.total ?? 0) > 0}
onConfigureModel={onConfigureModel}
/>
</>
) : null}
{simplified && !lite && pendingAdd ? (
<p className="memory-pending-add" role="status">
<span>
{t("memory:正在等待整理:")}
@@ -993,7 +1089,7 @@ export function MemorySpace({
id="memory-mode-panel-history"
hidden={displayedMode !== "history"}
>
<SdkRawHistory />
<SdkRawHistory active={active && displayedMode === "history"} />
</div>
)}
@@ -1034,14 +1130,17 @@ 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"}
rawRecord={selectedRaw}
libraryView={
simplified
? {
query: sdkLibraryQuery,
source: sdkLibrarySource,
page: sdkLibrary.page,
page: sdkPage,
loading: sdkLibrary.loading,
failed: sdkLibrary.failed,
graphLoading: !sdkGraphSettled,
@@ -1059,14 +1158,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={
@@ -1074,7 +1177,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}
@@ -1167,10 +1270,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>
);
@@ -1232,6 +1337,49 @@ function ModelPrompt({
);
}
/** Core's circuit breaker paused model work because the provider keeps
* failing (bad key, wrong URL, missing model, provider down).
*
* Nothing is lost and nothing needs retrying by hand: the queued work stays
* pending and Core keeps probing on its own, so this only says what is wrong
* and where to fix it. It is driven by the polled summary alone and goes away
* on the first read that no longer carries `llm_backoff_error`. */
function ModelBackoffBanner({
processing,
onConfigureModel,
}: {
processing: MemoryProcessingSummary | null;
onConfigureModel(): void;
}) {
const { t, tn } = useGuiLanguage();
const code = processing?.llm_backoff_error;
if (!processing || !code) return null;
return (
<p
className="model-prompt"
role="status"
aria-label={t("memory:模型暂停处理")}
>
<span>
{tn(
"memory:模型配置有问题({reason}),记忆暂停处理,已有 {count} 条在排队",
processing.pending,
{
reason: t(
`memory:${llmBackoffReasonLabel(code)}` as Parameters<
typeof t
>[0],
),
},
)}
</span>
<button type="button" onClick={onConfigureModel}>
{t("memory:配置整理模型")}
</button>
</p>
);
}
function MemoryInspector({
detail,
busy,
@@ -1346,7 +1494,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:这条记忆的来源授权已撤回,暂时不能读取。",
@@ -1359,8 +1507,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.
+187 -1
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,10 @@ const probeProfile = vi.fn();
const activate = vi.fn();
const remove = vi.fn();
const update = 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 +124,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({
@@ -151,6 +226,19 @@ vi.mock("./use-model-profiles", () => ({
update,
}),
}));
vi.mock("./use-embedding-switch", () => ({
useEmbeddingSwitch: () => ({
current: switchCurrent,
plan: switchPlan,
busy: null,
errorCode: null,
planActivation,
startPlannedSwitch,
shouldUseLegacyActivation: () => false,
clearPlan: vi.fn(),
clearError: vi.fn(),
}),
}));
describe("ModelSettings", () => {
it("exposes distinct edit, add and test actions from the same owner without activating", async () => {
@@ -187,8 +275,14 @@ describe("ModelSettings", () => {
probes = {};
pendingProbe = null;
profiles = [profile];
switchPlan = null;
switchCurrent = null;
create.mockResolvedValue(profile);
update.mockResolvedValue({ ...preset, has_api_key: true, revision: 2 });
planActivation.mockResolvedValue({
needed: false,
reason: "same_embedding",
});
});
it("submits a generation-only profile and leaves embedding to the builtin", async () => {
@@ -399,6 +493,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: "编辑配置" }));
@@ -661,9 +811,45 @@ 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("hands a successful save to its card, clears only the key, and never tests or activates", async () => {
profiles = [];
const user = userEvent.setup();
+504 -348
View File
@@ -16,9 +16,11 @@ import {
useRef,
useState,
} from "react";
import { EmbeddingSwitchConfirmDialog } from "./EmbeddingSwitchExperience";
import { OptionMenu } from "./OptionMenu";
import { useOnboardingTranslation } from "./onboarding-language";
import { PROVIDER_PRESETS } from "./provider-presets";
import { useEmbeddingSwitch } from "./use-embedding-switch";
import { useModelProfiles } from "./use-model-profiles";
import "./model-setup-next.css";
@@ -216,6 +218,8 @@ export interface ModelSettingsActions {
test(profileId: string): Promise<unknown>;
}
export type ModelSettingsFocusSection = "generation" | "embedding";
/** What the owner asked for before the fence said no. */
type ModelOpenIntent =
| { kind: "add" }
@@ -225,15 +229,18 @@ export function ModelSettings({
controlsRef,
onProfilesChanged,
compactSingle = false,
focusSection,
onSetupComplete,
}: {
controlsRef?: Ref<ModelSettingsActions>;
onProfilesChanged?(): void;
compactSingle?: boolean;
focusSection?: ModelSettingsFocusSection;
onSetupComplete?(): void;
} = {}) {
const { text } = useOnboardingTranslation();
const models = useModelProfiles();
const embeddingSwitch = useEmbeddingSwitch();
const [provider, setProvider] = useState<ProviderKind>("openai_compatible");
const [providerPreset, setProviderPreset] = useState("custom");
const [advancedEmbedding, setAdvancedEmbedding] = useState(false);
@@ -263,6 +270,9 @@ export function ModelSettings({
null,
);
const [setupError, setSetupError] = useState<string | null>(null);
const [switchTarget, setSwitchTarget] = useState<ModelProfile | null>(null);
const [switchJobId, setSwitchJobId] = useState<string | null>(null);
const settledSwitchJob = useRef<string | null>(null);
const flight = useRef(false);
const alive = useRef(true);
const queuedOpen = useRef<ModelOpenIntent | null>(null);
@@ -274,6 +284,41 @@ export function ModelSettings({
alive.current = false;
};
}, []);
useEffect(() => {
const finished = embeddingSwitch.current?.last_finished;
if (
!switchTarget ||
!switchJobId ||
!finished ||
finished.job_id !== switchJobId ||
settledSwitchJob.current === finished.job_id
)
return;
settledSwitchJob.current = finished.job_id;
if (finished.state === "done") {
void models.refreshProfiles();
setSetupError(null);
setSwitchTarget(null);
setSwitchJobId(null);
onProfilesChanged?.();
return;
}
if (finished.state === "rolled_back") {
setSetupError(
finished.error_code === "embedding_switch_cancelled"
? "索引更新已取消,仍在使用原来的搜索模型。"
: "索引更新没有完成,仍在使用原来的搜索模型,记忆没有丢失。",
);
setSwitchTarget(null);
setSwitchJobId(null);
}
}, [
embeddingSwitch.current?.last_finished,
models.refreshProfiles,
onProfilesChanged,
switchJobId,
switchTarget,
]);
useLayoutEffect(() => {
if (saved) savedCard.current?.focus({ preventScroll: true });
}, [saved]);
@@ -452,6 +497,43 @@ export function ModelSettings({
onProfilesChanged?.();
}, [profileSnapshot, onProfilesChanged]);
async function activateWithPlan(
profile: ModelProfile,
result: ModelProbeResult | null,
): Promise<boolean> {
setSetupPhase("activating");
const switchPlan = embeddingSwitch.shouldUseLegacyActivation()
? null
: await embeddingSwitch.planActivation(
profile.profile_id,
profile.revision,
);
if (!alive.current) return false;
if (!switchPlan && !embeddingSwitch.shouldUseLegacyActivation()) {
setSetupError("暂时无法检查搜索模型更换影响,配置已保存,请稍后重试。");
return false;
}
if (switchPlan?.needed) {
setSwitchTarget(profile);
setSetupPhase(null);
return false;
}
const active = await models.activate(profile.profile_id);
if (!alive.current) return false;
if (
!active ||
active.profile_id !== profile.profile_id ||
!active.is_active ||
!ready(active, result)
) {
setSetupError("配置已保存,尚未确认启用成功。请核对配置后重试。");
return false;
}
if (profile.name !== PRESET_PROFILE_NAME) setSaved(active);
onSetupComplete?.();
return true;
}
async function finishSetup(profile: ModelProfile): Promise<boolean> {
setSetupPhase("testing");
const result = await models.probeProfile(profile.profile_id);
@@ -487,21 +569,24 @@ export function ModelSettings({
setFields(probeFieldErrors(result));
return false;
}
setSetupPhase("activating");
const active = await models.activate(profile.profile_id);
if (!alive.current) return false;
if (
!active ||
active.profile_id !== profile.profile_id ||
!active.is_active ||
!ready(active, result)
) {
setSetupError("配置已保存,尚未确认启用成功。请核对配置后重试。");
return false;
return activateWithPlan(profile, result);
}
async function activateTested(profile: ModelProfile) {
const result = models.probeFor(profile);
if (!isReady(profile) || flight.current || locked) return;
flight.current = true;
setSaving(true);
setSetupError(null);
try {
await activateWithPlan(profile, result);
} finally {
flight.current = false;
if (alive.current) {
setSaving(false);
setSetupPhase(null);
}
}
if (profile.name !== PRESET_PROFILE_NAME) setSaved(active);
onSetupComplete?.();
return true;
}
async function resumeSetup(profile: ModelProfile, key?: string) {
@@ -572,12 +657,14 @@ export function ModelSettings({
)
return;
const errors: Partial<Record<FieldName, string>> = {};
if (!model.trim()) errors.model = "请填写生成模型名。";
if (!validEndpoint(baseUrl))
errors["base-url"] = "请填写完整的 http:// 或 https:// 接口地址。";
const credentialError = keyError(apiKey, keyRequired);
if (credentialError) errors["api-key"] = credentialError;
if (embeddingMode === "custom") {
if (focusSection !== "embedding") {
if (!model.trim()) errors.model = "请填写生成模型名。";
if (!validEndpoint(baseUrl))
errors["base-url"] = "请填写完整的 http:// 或 https:// 接口地址。";
const credentialError = keyError(apiKey, keyRequired);
if (credentialError) errors["api-key"] = credentialError;
}
if (focusSection !== "generation" && embeddingMode === "custom") {
if (!embeddingModel.trim())
errors["embedding-model"] = "请填写向量模型名。";
if (!validEndpoint(embeddingBaseUrl))
@@ -619,14 +706,16 @@ export function ModelSettings({
? { expected_revision: editing.revision }
: { expected_revision: 0 };
if (editing) {
if (input.name !== editing.name) patch.name = input.name;
if (input.model !== editing.model) patch.model = input.model;
if (input.base_url !== editing.base_url)
patch.base_url = input.base_url;
if (input.disable_thinking !== editing.disable_thinking)
patch.disable_thinking = input.disable_thinking;
if (apiKey) patch.api_key = apiKey;
if (embeddingMode === "custom") {
if (focusSection !== "embedding") {
if (input.name !== editing.name) patch.name = input.name;
if (input.model !== editing.model) patch.model = input.model;
if (input.base_url !== editing.base_url)
patch.base_url = input.base_url;
if (input.disable_thinking !== editing.disable_thinking)
patch.disable_thinking = input.disable_thinking;
if (apiKey) patch.api_key = apiKey;
}
if (focusSection !== "generation" && embeddingMode === "custom") {
if (embeddingModel !== editing.embedding_model)
patch.embedding_model = embeddingModel;
if (embeddingProvider !== editing.embedding_provider_kind)
@@ -636,6 +725,7 @@ export function ModelSettings({
if (embeddingBaseUrl !== (editing.embedding_base_url ?? ""))
patch.embedding_base_url = embeddingBaseUrl;
} else if (
focusSection !== "generation" &&
embeddingModeChanged &&
editing.embedding_provider_kind !== BUILTIN_EMBEDDING_KIND
) {
@@ -699,11 +789,23 @@ export function ModelSettings({
<div className="section-heading">
<div>
<p className="section-index">02</p>
<h2 id="models-title">{text("记忆模型")}</h2>
<h2 id="models-title">
{text(
focusSection === "generation"
? "记忆整理"
: focusSection === "embedding"
? "记忆搜索"
: "记忆模型",
)}
</h2>
</div>
<p>
{text(
"只需配置生成模型。向量嵌入已内置在本机,不为 System 1、System 2 分开选模型。",
focusSection === "generation"
? "配置用于理解会话、整理记忆的模型。"
: focusSection === "embedding"
? "推荐使用本地内置搜索;需要连接自定义服务时再展开配置。"
: "只需配置生成模型。向量嵌入已内置在本机,不为 System 1、System 2 分开选模型。",
)}
</p>
</div>
@@ -956,326 +1058,359 @@ export function ModelSettings({
{text("刷新配置列表")}
</button>
)}
<div className="model-form-field">
{text("服务商预设")}
<OptionMenu
ariaLabel={text("服务商预设")}
describedBy="model-preset-help"
value={providerPreset}
disabled={formLocked}
options={[
...PROVIDER_PRESETS.map((item) => ({
value: item.id,
label: item.label,
})),
{ value: "custom", label: text("自定义兼容服务") },
]}
onChange={(value) => {
const next = PROVIDER_PRESETS.find((item) => item.id === value);
setProviderPreset(value);
if (!next) return;
setProvider(next.provider);
setBaseUrl(next.baseUrl);
setModel(next.model);
setApiKey("");
setDisableThinking(true);
if (editing) {
setName(next.label);
setNameEdited(true);
} else if (!nameEdited) {
setName(suggestedName(next.model, next.provider));
}
setFields({});
setSaveError(null);
}}
/>
<small id="model-preset-help">
{text(
"只是帮你填好接口地址和一个模型名,不代表这个模型当前可用。 请对照服务商的模型列表核对后再保存。",
)}
</small>
</div>
<label>
{text("协议")}
<select
name="provider"
aria-label={text("协议")}
aria-describedby="model-provider-help"
value={provider}
disabled={formLocked}
onChange={(event) => {
const next = event.target.value as ProviderKind;
setProvider(next);
setProviderPreset("custom");
if (!nameEdited) setName(suggestedName(model, next));
}}
>
{/* Only what the backend will actually activate. These used to be
* listed and disabled, which put two dead ends on screen and made
* the reader parse a parenthesis to find out they were dead. */}
{PROVIDERS.filter((item) => item.activatable).map((item) => (
<option key={item.kind} value={item.kind}>
{text(item.label)}
</option>
))}
</select>
<small id="model-provider-help">
{text("与模型服务提供的接口协议保持一致。")}
</small>
</label>
<label>
{text("配置名称")}
<input
name="name"
aria-label={text("配置名称")}
aria-describedby="model-name-help"
autoComplete="off"
maxLength={128}
value={name}
disabled={formLocked}
onChange={(event) => {
setNameEdited(true);
setName(event.target.value);
}}
/>
<small id="model-name-help">
{text("用于区分配置,不影响模型请求。")}
</small>
</label>
<label>
{text("生成模型名")}
<input
name="model"
aria-label={text("生成模型名")}
aria-describedby={`model-identifier-help${fields.model ? " model-field-error" : ""}`}
aria-invalid={!!fields.model}
autoComplete="off"
required
maxLength={256}
value={model}
disabled={formLocked}
onChange={(event) => {
setModel(event.target.value);
setProviderPreset("custom");
if (!nameEdited) setName(suggestedName(event.target.value));
}}
placeholder={text("例如:gpt-4o-mini…")}
/>
<small id="model-identifier-help">
{text("填写服务方提供的完整模型标识。")}
</small>
{fields.model && (
<small id="model-field-error" className="model-field-error">
{text(fields.model)}
</small>
)}
</label>
{/* Disclosure only changes visibility; mode determines what is saved. */}
<div className="model-advanced">
<button
type="button"
className="quiet"
aria-expanded={advancedEmbedding}
disabled={formLocked}
onClick={() => setAdvancedEmbedding(!advancedEmbedding)}
>
{text("高级:自行配置向量模型")}
</button>
{advancedEmbedding ? (
<>
<label>
{text("向量配置模式")}
<select
name="embedding-mode"
value={embeddingMode}
disabled={formLocked}
onChange={(event) => {
setEmbeddingMode(
event.target.value as "local" | "custom",
);
setEmbeddingModeChanged(true);
setFields((previous) => ({
...previous,
"embedding-model": undefined,
"embedding-base-url": undefined,
}));
}}
>
<option value="local">{text("本地内置")}</option>
<option value="custom">{text("自定义向量模型")}</option>
</select>
</label>
{embeddingMode === "custom" ? (
<>
<label>
{text("向量模型名")}
<input
name="embedding-model"
autoComplete="off"
required
maxLength={256}
value={embeddingModel}
disabled={formLocked}
aria-invalid={!!fields["embedding-model"]}
aria-describedby={
fields["embedding-model"]
? "model-embedding-error"
: undefined
}
onChange={(event) =>
setEmbeddingModel(event.target.value)
}
placeholder={text("例如:text-embedding-3-small…")}
/>
{fields["embedding-model"] && (
<small
id="model-embedding-error"
className="model-field-error"
>
{text(fields["embedding-model"])}
</small>
)}
</label>
<label>
{text("向量模型来源")}
<select
name="embedding-provider"
value={embeddingProvider}
disabled={formLocked}
onChange={(event) => {
const next = event.target.value as
| "openai_compatible"
| "ollama";
setEmbeddingProvider(next);
}}
>
<option value="ollama">{text("本机 Ollama")}</option>
<option value="openai_compatible">
{text("同源 OpenAI-compatible(共用 API Key)")}
</option>
</select>
</label>
<label>
{text("向量模型 Base URL")}
<input
name="embedding-base-url"
type="url"
inputMode="url"
autoComplete="off"
required
value={embeddingBaseUrl}
disabled={formLocked}
placeholder={
embeddingProvider === "ollama"
? "http://127.0.0.1:11434"
: baseUrl
}
aria-invalid={!!fields["embedding-base-url"]}
aria-describedby={
fields["embedding-base-url"]
? "model-embedding-url-error"
: undefined
}
onChange={(event) =>
setEmbeddingBaseUrl(event.target.value)
}
/>
{fields["embedding-base-url"] && (
<small
id="model-embedding-url-error"
className="model-field-error"
>
{text(fields["embedding-base-url"])}
</small>
)}
</label>
{embeddingProvider === "openai_compatible" ? (
<small>
{text("为避免凭据外泄,向量地址必须与生成模型同源。")}
</small>
) : null}
</>
) : (
<small>
{text("使用内置本地向量模型;不会发送内容给远程向量服务。")}
{focusSection !== "embedding" && (
<>
<div className="model-form-field">
{text("服务商预设")}
<OptionMenu
ariaLabel={text("服务商预设")}
describedBy="model-preset-help"
value={providerPreset}
disabled={formLocked}
options={[
...PROVIDER_PRESETS.map((item) => ({
value: item.id,
label: item.label,
})),
{ value: "custom", label: text("自定义兼容服务") },
]}
onChange={(value) => {
const next = PROVIDER_PRESETS.find(
(item) => item.id === value,
);
setProviderPreset(value);
if (!next) return;
setProvider(next.provider);
setBaseUrl(next.baseUrl);
setModel(next.model);
setApiKey("");
setDisableThinking(true);
if (editing) {
setName(next.label);
setNameEdited(true);
} else if (!nameEdited) {
setName(suggestedName(next.model, next.provider));
}
setFields({});
setSaveError(null);
}}
/>
<small id="model-preset-help">
{text(
"只是帮你填好接口地址和一个模型名,不代表这个模型当前可用。 请对照服务商的模型列表核对后再保存。",
)}
</small>
</div>
<label>
{text("协议")}
<select
name="provider"
aria-label={text("协议")}
aria-describedby="model-provider-help"
value={provider}
disabled={formLocked}
onChange={(event) => {
const next = event.target.value as ProviderKind;
setProvider(next);
setProviderPreset("custom");
if (!nameEdited) setName(suggestedName(model, next));
}}
>
{/* Only what the backend will actually activate. These used to be
* listed and disabled, which put two dead ends on screen and made
* the reader parse a parenthesis to find out they were dead. */}
{PROVIDERS.filter((item) => item.activatable).map((item) => (
<option key={item.kind} value={item.kind}>
{text(item.label)}
</option>
))}
</select>
<small id="model-provider-help">
{text("与模型服务提供的接口协议保持一致。")}
</small>
</label>
<label>
{text("配置名称")}
<input
name="name"
aria-label={text("配置名称")}
aria-describedby="model-name-help"
autoComplete="off"
maxLength={128}
value={name}
disabled={formLocked}
onChange={(event) => {
setNameEdited(true);
setName(event.target.value);
}}
/>
<small id="model-name-help">
{text("用于区分配置,不影响模型请求。")}
</small>
</label>
<label>
{text("生成模型名")}
<input
name="model"
aria-label={text("生成模型名")}
aria-describedby={`model-identifier-help${fields.model ? " model-field-error" : ""}`}
aria-invalid={!!fields.model}
autoComplete="off"
required
maxLength={256}
value={model}
disabled={formLocked}
onChange={(event) => {
setModel(event.target.value);
setProviderPreset("custom");
if (!nameEdited) setName(suggestedName(event.target.value));
}}
placeholder={text("例如:gpt-4o-mini…")}
/>
<small id="model-identifier-help">
{text("填写服务方提供的完整模型标识。")}
</small>
{fields.model && (
<small id="model-field-error" className="model-field-error">
{text(fields.model)}
</small>
)}
</>
) : null}
{!advancedEmbedding && embeddingMode === "custom" ? (
<p className="model-help">
{text("自定义向量")}: {embeddingModel || text("尚未填写模型")} ·{" "}
{embeddingBaseUrl || text("尚未填写地址")}
</p>
) : null}
</div>
<label>
Base URL
<input
name="base-url"
aria-label="Base URL"
aria-describedby={`model-url-help${fields["base-url"] ? " model-url-error" : ""}`}
aria-invalid={!!fields["base-url"]}
type="url"
inputMode="url"
autoComplete="off"
required={provider !== "hy_default"}
disabled={formLocked || provider === "hy_default"}
value={baseUrl}
onChange={(event) => {
setBaseUrl(event.target.value);
setProviderPreset("custom");
}}
/>
<small id="model-url-help">
{text("填写服务接口地址,不是聊天网页地址。")}
</small>
{fields["base-url"] && (
<small id="model-url-error" className="model-field-error">
{text(fields["base-url"])}
</small>
)}
</label>
<label>
API Key{keyRequired ? "" : text("(可选)")}
<input
name="api-key"
aria-label={`API Key${keyRequired ? "" : text("(可选)")}`}
aria-describedby={`model-key-help${fields["api-key"] ? " model-key-error" : ""}`}
aria-invalid={!!fields["api-key"]}
type="password"
autoComplete="off"
required={keyRequired}
value={apiKey}
disabled={formLocked}
onChange={(event) => setApiKey(event.target.value)}
placeholder={text("保存后不会再次显示…")}
/>
<small id="model-key-help">
{text(
editing && !endpointMoved
? "留空保留已保存密钥;填写新密钥后需重新测试和启用。"
: editing && keyRequired
? "换了服务商后需要填写新的 API Key,已保存的密钥不会带到新地址。"
: "保存在系统安全存储中,不随安装包分发。",
</label>
</>
)}
{/* Disclosure only changes visibility; mode determines what is saved. */}
{focusSection !== "generation" && (
<div className="model-advanced">
{focusSection === "embedding" && (
<div className="model-search-intro">
<strong>{text("搜索方式")}</strong>
<p>
{text(
"本地内置适合大多数人:无需密钥,内容不会发送到远程向量服务。",
)}
</p>
</div>
)}
</small>
{fields["api-key"] && (
<small id="model-key-error" className="model-field-error">
{text(fields["api-key"])}
</small>
)}
</label>
<label>
<input
name="disable-thinking"
type="checkbox"
checked={disableThinking}
disabled={formLocked}
onChange={(event) => setDisableThinking(event.target.checked)}
/>
{text("关闭思考链(向支持的厂商下发 thinking-off 参数)")}
</label>
{focusSection !== "embedding" && (
<button
type="button"
className="quiet"
aria-expanded={advancedEmbedding}
disabled={formLocked}
onClick={() => setAdvancedEmbedding(!advancedEmbedding)}
>
{text("高级:自行配置向量模型")}
</button>
)}
{focusSection === "embedding" || advancedEmbedding ? (
<>
<label>
{text(
focusSection === "embedding"
? "搜索模型"
: "向量配置模式",
)}
<select
name="embedding-mode"
value={embeddingMode}
disabled={formLocked}
onChange={(event) => {
setEmbeddingMode(
event.target.value as "local" | "custom",
);
setEmbeddingModeChanged(true);
setFields((previous) => ({
...previous,
"embedding-model": undefined,
"embedding-base-url": undefined,
}));
}}
>
<option value="local">{text("本地内置(推荐)")}</option>
<option value="custom">{text("自定义搜索模型")}</option>
</select>
</label>
<small>{text("技术类型:Embedding")}</small>
{embeddingMode === "custom" ? (
<>
<label>
{text("向量模型名")}
<input
name="embedding-model"
autoComplete="off"
required
maxLength={256}
value={embeddingModel}
disabled={formLocked}
aria-invalid={!!fields["embedding-model"]}
aria-describedby={
fields["embedding-model"]
? "model-embedding-error"
: undefined
}
onChange={(event) =>
setEmbeddingModel(event.target.value)
}
placeholder={text("例如:text-embedding-3-small…")}
/>
{fields["embedding-model"] && (
<small
id="model-embedding-error"
className="model-field-error"
>
{text(fields["embedding-model"])}
</small>
)}
</label>
<label>
{text("向量模型来源")}
<select
name="embedding-provider"
value={embeddingProvider}
disabled={formLocked}
onChange={(event) => {
const next = event.target.value as
| "openai_compatible"
| "ollama";
setEmbeddingProvider(next);
}}
>
<option value="ollama">{text("本机 Ollama")}</option>
<option value="openai_compatible">
{text("同源 OpenAI-compatible(共用 API Key)")}
</option>
</select>
</label>
<label>
{text("向量模型 Base URL")}
<input
name="embedding-base-url"
type="url"
inputMode="url"
autoComplete="off"
required
value={embeddingBaseUrl}
disabled={formLocked}
placeholder={
embeddingProvider === "ollama"
? "http://127.0.0.1:11434"
: baseUrl
}
aria-invalid={!!fields["embedding-base-url"]}
aria-describedby={
fields["embedding-base-url"]
? "model-embedding-url-error"
: undefined
}
onChange={(event) =>
setEmbeddingBaseUrl(event.target.value)
}
/>
{fields["embedding-base-url"] && (
<small
id="model-embedding-url-error"
className="model-field-error"
>
{text(fields["embedding-base-url"])}
</small>
)}
</label>
{embeddingProvider === "openai_compatible" ? (
<small>
{text("为避免凭据外泄,向量地址必须与生成模型同源。")}
</small>
) : null}
</>
) : (
<small>
{text(
"使用内置本地向量模型;不会发送内容给远程向量服务。",
)}
</small>
)}
</>
) : null}
{focusSection !== "embedding" &&
!advancedEmbedding &&
embeddingMode === "custom" ? (
<p className="model-help">
{text("自定义向量")}: {embeddingModel || text("尚未填写模型")}{" "}
· {embeddingBaseUrl || text("尚未填写地址")}
</p>
) : null}
</div>
)}
{focusSection !== "embedding" && (
<>
<label>
Base URL
<input
name="base-url"
aria-label="Base URL"
aria-describedby={`model-url-help${fields["base-url"] ? " model-url-error" : ""}`}
aria-invalid={!!fields["base-url"]}
type="url"
inputMode="url"
autoComplete="off"
required={provider !== "hy_default"}
disabled={formLocked || provider === "hy_default"}
value={baseUrl}
onChange={(event) => {
setBaseUrl(event.target.value);
setProviderPreset("custom");
}}
/>
<small id="model-url-help">
{text("填写服务接口地址,不是聊天网页地址。")}
</small>
{fields["base-url"] && (
<small id="model-url-error" className="model-field-error">
{text(fields["base-url"])}
</small>
)}
</label>
<label>
API Key{keyRequired ? "" : text("(可选)")}
<input
name="api-key"
aria-label={`API Key${keyRequired ? "" : text("(可选)")}`}
aria-describedby={`model-key-help${fields["api-key"] ? " model-key-error" : ""}`}
aria-invalid={!!fields["api-key"]}
type="password"
autoComplete="off"
required={keyRequired}
value={apiKey}
disabled={formLocked}
onChange={(event) => setApiKey(event.target.value)}
placeholder={text("保存后不会再次显示…")}
/>
<small id="model-key-help">
{text(
editing && !endpointMoved
? "留空保留已保存密钥;填写新密钥后需重新测试和启用。"
: editing && keyRequired
? "换了服务商后需要填写新的 API Key,已保存的密钥不会带到新地址。"
: "保存在系统安全存储中,不随安装包分发。",
)}
</small>
{fields["api-key"] && (
<small id="model-key-error" className="model-field-error">
{text(fields["api-key"])}
</small>
)}
</label>
<label>
<input
name="disable-thinking"
type="checkbox"
checked={disableThinking}
disabled={formLocked}
onChange={(event) => setDisableThinking(event.target.checked)}
/>
{text("关闭思考链(向支持的厂商下发 thinking-off 参数)")}
</label>
</>
)}
<div className="form-submit">
{!compactSingle && (
<button
@@ -1397,7 +1532,7 @@ export function ModelSettings({
<button
type="button"
disabled={locked || !isReady(profile)}
onClick={() => models.activate(profile.profile_id)}
onClick={() => void activateTested(profile)}
>
{text("设为当前模型")}
</button>
@@ -1421,6 +1556,27 @@ export function ModelSettings({
))}
</div>
)}
{embeddingSwitch.plan?.needed && switchTarget ? (
<EmbeddingSwitchConfirmDialog
plan={embeddingSwitch.plan}
busy={embeddingSwitch.busy === "start"}
errorCode={embeddingSwitch.errorCode}
onCancel={() => {
embeddingSwitch.clearPlan();
embeddingSwitch.clearError();
setSwitchTarget(null);
setSwitchJobId(null);
}}
onStart={() => {
void embeddingSwitch.startPlannedSwitch().then((job) => {
if (!job || !alive.current) return;
setSwitchJobId(job.job_id);
setSetupError(null);
onSetupComplete?.();
});
}}
/>
) : null}
</section>
);
}
@@ -8,7 +8,15 @@ 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" }),
}));
vi.mock("./FirstImportReview", () => ({
defaultHostSourceIds: () => [],
FirstImportReview: ({
@@ -121,10 +129,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 +158,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: "继续" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
@@ -168,6 +181,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: "继续" }));
await screen.findByRole("button", { name: "重试保存并进入" });
expect(finish).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: "仍进入应用页" }));
@@ -25,6 +25,12 @@ 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" }),
}));
const models = {
pendingProbe: false,
@@ -393,6 +399,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: "Continuar" }));
expect(
await screen.findByRole("button", { name: "Reintentar guardar y entrar" }),
).toBeTruthy();
@@ -26,6 +26,12 @@ 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" }),
getSourceConnection: vi.fn(),
createImportJob: vi.fn(),
detectSource: vi.fn(),
@@ -161,6 +167,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 +301,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: "继续" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
@@ -317,6 +353,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: "继续" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
@@ -13,7 +13,15 @@ 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" }),
}));
vi.mock("./FirstImportReview", () => ({
FirstImportReview: ({ sourceIds }: { sourceIds: string[] }) => (
<p>已受理 {sourceIds.join(",")}</p>
+115 -21
View File
@@ -25,6 +25,8 @@ import {
} from "./use-import-sources";
const completeOnboarding = vi.fn();
const setMemoryMode = vi.fn();
const getMemoryMode = vi.fn();
const getModelProfiles = vi.fn();
const getMemoryProcessing = vi.fn();
const previewSourceDirectory = vi.fn();
@@ -44,6 +46,8 @@ 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),
getModelProfiles: (...args: unknown[]) => getModelProfiles(...args),
getMemoryProcessing: (...args: unknown[]) => getMemoryProcessing(...args),
previewSourceDirectory: (...args: unknown[]) =>
@@ -176,9 +180,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: "继续" }));
}
function choice(name = "Claude Code") {
return screen.getByRole<HTMLInputElement>("checkbox", {
name: `导入 ${name} 历史`,
@@ -218,6 +231,12 @@ describe("OnboardingFlow S1.3", () => {
...onboarding,
status: "completed",
});
setMemoryMode.mockResolvedValue({ mode: "pro" });
getMemoryMode.mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: false,
});
});
afterEach(() => {
cleanup();
@@ -355,7 +374,7 @@ describe("OnboardingFlow S1.3", () => {
name: "配置模型,沉淀记忆",
});
expect(continueButton).toHaveProperty("disabled", false);
await user.click(continueButton);
await openModelStep(user);
const modelBoundary = screen.getByRole("region", {
name: "模型步骤边界",
});
@@ -365,6 +384,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 +408,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 +667,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 +694,89 @@ 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: "继续" }));
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("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: "继续" }));
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: "继续" }));
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: "继续" }));
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledTimes(2));
expect(setMemoryMode).toHaveBeenLastCalledWith("lite");
expect(completeOnboarding).toHaveBeenCalledOnce();
expect(onFinish).toHaveBeenCalledWith("sources");
});
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 +790,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: "继续" }));
expect(completeOnboarding).toHaveBeenCalledOnce();
expect(onFinish).toHaveBeenCalledWith("sources");
expect(getModelProfiles).not.toHaveBeenCalled();
@@ -721,6 +816,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 +871,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 +895,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");
});
@@ -1868,6 +1966,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 +2141,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 +2243,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 +2290,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();
});
@@ -2355,9 +2451,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);
+187 -12
View File
@@ -15,7 +15,7 @@ import {
useState,
} from "react";
import { completeOnboarding } from "./api";
import { completeOnboarding, getMemoryMode, setMemoryMode } from "./api";
import { AppMark } from "./app-mark";
import { saveConnectionPreferences } from "./connection-preferences";
import {
@@ -38,6 +38,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";
@@ -88,7 +89,18 @@ export function OnboardingFlow({
const [startingImport, setStartingImport] = useState<SourceResponse[] | null>(
null,
);
const [modelStep, setModelStep] = useState(false);
const [setupStep, setSetupStep] = useState<"review" | "mode" | "model">(
"review",
);
const [modeChoice, setModeChoice] = useState<OnboardingMemoryMode | null>(
null,
);
const [modeDestination, setModeDestination] =
useState<OnboardingDestination>("sources");
const [modeSaving, setModeSaving] = useState(false);
const [modeError, setModeError] = useState<string | null>(null);
const modeChoiceTouched = useRef(false);
const [visitedSetup, setVisitedSetup] = useState(false);
const [visitedModel, setVisitedModel] = useState(false);
const [modelSourceIds, setModelSourceIds] = useState<string[]>([]);
const [connectionDraft, setConnectionDraft] = useState<ImportConnectionDraft>(
@@ -135,7 +147,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 +319,20 @@ export function OnboardingFlow({
useEffect(() => {
alive.current = true;
void getMemoryMode().then(
(memoryMode) => {
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;
};
@@ -421,7 +447,7 @@ export function OnboardingFlow({
});
if (alive.current) {
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 +476,44 @@ export function OnboardingFlow({
[persistFinish],
);
const requestModeChoice = useCallback(
(destination: OnboardingDestination) => {
if (finished.current) return;
setModeDestination(destination === "settings" ? "sources" : destination);
setModeError(null);
setError(null);
setVisitedSetup(true);
setSetupStep("mode");
},
[],
);
const saveModeChoice = useCallback(async () => {
if (
!modeChoice ||
modeSaving ||
finished.current ||
(modeChoice === "lite" && flight.current)
)
return;
setModeSaving(true);
setModeError(null);
try {
await setMemoryMode(modeChoice);
if (!alive.current) return;
if (modeChoice === "lite") {
await leave(modeDestination);
return;
}
setVisitedModel(true);
setSetupStep("model");
} catch {
if (alive.current) setModeError("模式未能保存。你的选择已保留,请重试。");
} finally {
if (alive.current) setModeSaving(false);
}
}, [leave, modeChoice, modeDestination, modeSaving]);
async function checkLocations() {
if (flight.current || locked) return;
flight.current = true;
@@ -636,7 +700,7 @@ export function OnboardingFlow({
return (
<>
<div
hidden={modelStep}
hidden={setupStep !== "review"}
data-import-submission={startingImport ? "pending" : "settled"}
>
<FirstImportReview
@@ -663,6 +727,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 +744,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,8 +757,116 @@ export function OnboardingFlow({
}
/>
</div>
{setupStep === "mode" && (
<R6ColdStartFrame
step={3}
title={text("选择记忆方式")}
eyebrow={text("最后一步 · 决定如何处理对话")}
footer={
<>
<R6Button
variant="quiet"
disabled={modeSaving || busy === "leaving"}
onClick={() => {
setModeError(null);
setSetupStep("review");
}}
>
{text("返回导入")}
</R6Button>
<R6Button
className="onboarding-r6-submit"
variant="primary"
pending={modeSaving}
disabled={
!modeChoice ||
busy === "leaving" ||
(modeChoice === "lite" && flight.current)
}
onClick={() => void saveModeChoice()}
>
{text(modeSaving ? "正在保存…" : "继续")}
</R6Button>
</>
}
>
<section
className="onboarding-r6-mode"
aria-labelledby="onboarding-memory-mode-title"
>
<div className="onboarding-r6-mode-intro">
<h2 id="onboarding-memory-mode-title">
{text("你希望 MemHub 如何形成记忆?")}
</h2>
<p>{text("需要明确选择一种方式;之后仍可在设置中更改。")}</p>
</div>
<fieldset
className="onboarding-r6-mode-options"
disabled={modeSaving || busy === "leaving"}
>
<legend className="onboarding-r6-sr-only">
{text("记忆模式")}
</legend>
<label
className="onboarding-r6-mode-card"
data-selected={modeChoice === "pro"}
>
<input
type="radio"
name="onboarding-memory-mode"
value="pro"
checked={modeChoice === "pro"}
onChange={() => {
modeChoiceTouched.current = true;
setModeChoice("pro");
setModeError(null);
}}
/>
<span className="onboarding-r6-mode-copy">
<span className="onboarding-r6-mode-heading">
<strong>pro</strong>
<span>{text("推荐")}</span>
</span>
<span>
{text("使用模型理解对话,提取并整理可持续生长的记忆。")}
</span>
</span>
</label>
<label
className="onboarding-r6-mode-card"
data-selected={modeChoice === "lite"}
>
<input
type="radio"
name="onboarding-memory-mode"
value="lite"
checked={modeChoice === "lite"}
onChange={() => {
modeChoiceTouched.current = true;
setModeChoice("lite");
setModeError(null);
}}
/>
<span className="onboarding-r6-mode-copy">
<span className="onboarding-r6-mode-heading">
<strong>lite</strong>
</span>
<span>
{text("无需模型,只保存并检索原始对话,不生成提炼记忆。")}
</span>
</span>
</label>
</fieldset>
{(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
connectionAlreadyApplied={
connectionApplication === "applied" ||
@@ -709,8 +884,8 @@ export function OnboardingFlow({
(total, source) => total + source.imported_conversations,
0,
)}
onBack={() => setModelStep(false)}
onSkip={() => void leave("sources")}
onBack={() => setSetupStep("mode")}
onSkip={() => setSetupStep("mode")}
/>
</div>
)}
@@ -763,7 +938,7 @@ export function OnboardingFlow({
<R6Button
variant="quiet"
disabled={locked}
onClick={() => void leave("sources")}
onClick={() => requestModeChoice("sources")}
>
{text("稍后添加,先进入")}
</R6Button>
@@ -0,0 +1,83 @@
// @vitest-environment jsdom
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { OnboardingModelController } from "./OnboardingModelController";
const { models } = vi.hoisted(() => ({
models: {
profiles: [
{
profile_id: "active-profile",
revision: 3,
name: "Current model",
provider_kind: "openai_compatible",
model: "model-1",
base_url: "https://models.example/v1",
disable_thinking: true,
embedding_provider_kind: "local_onnx",
embedding_model: "bge-small-zh-v1.5",
embedding_base_url: null,
timeout_seconds: 90,
max_concurrency: 4,
context_window: null,
has_api_key: true,
is_active: true,
},
],
pendingProbe: false,
probeStorageFailed: false,
pendingCreation: false,
creationStorageFailed: false,
error: null,
create: vi.fn(),
update: vi.fn(),
probeProfile: vi.fn(),
activate: vi.fn(),
},
}));
vi.mock("./use-model-profiles", () => ({
useModelProfiles: () => models,
}));
vi.mock("./OnboardingModelStep", () => ({
OnboardingModelStep: (props: {
probe?: { message: string } | null;
enableActionLabel?: string;
onSaveAndEnable(): Promise<void>;
}) => (
<section>
<p>{props.probe?.message}</p>
<button type="button" onClick={() => void props.onSaveAndEnable()}>
{props.enableActionLabel}
</button>
</section>
),
}));
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("resumes onboarding with an already active model after a restart", async () => {
const enabled = vi.fn();
render(
<OnboardingModelController
sourceNames={[]}
onBack={vi.fn()}
onEnabled={enabled}
/>,
);
expect(
await screen.findByText("当前模型已经启用,可以继续完成首次设置。"),
).toBeTruthy();
await userEvent.click(
screen.getByRole("button", { name: "继续使用当前模型" }),
);
await waitFor(() => expect(enabled).toHaveBeenCalledOnce());
expect(models.activate).not.toHaveBeenCalled();
});
+173 -72
View File
@@ -3,8 +3,9 @@ import type {
ModelProfile,
ModelProfileInput,
} from "@one-memory/contracts";
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { getModelProfiles } from "./api";
import { EmbeddingSwitchConfirmDialog } from "./EmbeddingSwitchExperience";
import {
type OnboardingModelDraft,
OnboardingModelStep,
@@ -12,6 +13,7 @@ import {
import { useOnboardingTranslation } from "./onboarding-language";
import { PROVIDER_PRESETS } from "./provider-presets";
import { traceUiAction } from "./ui-trace";
import { useEmbeddingSwitch } from "./use-embedding-switch";
import { useModelProfiles } from "./use-model-profiles";
const defaultPreset = PROVIDER_PRESETS.find(
@@ -68,6 +70,7 @@ export function OnboardingModelController({
}) {
const { text, t } = useOnboardingTranslation();
const models = useModelProfiles();
const embeddingSwitch = useEmbeddingSwitch();
const [draft, setDraft] = useState(initial);
const [advanced, setAdvanced] = useState(false);
const [saved, setSaved] = useState<ModelProfile | null>(null);
@@ -82,10 +85,66 @@ export function OnboardingModelController({
state: "passed" | "failed" | "stale";
message: string;
} | null>(null);
const [switchPendingJobId, setSwitchPendingJobId] = useState<string | null>(
null,
);
const flight = useRef(false);
const draftTouched = useRef(false);
useEffect(() => {
const active = (models.profiles ?? []).find((profile) => profile.is_active);
if (!active || saved || draftTouched.current) return;
setSaved(active);
setDraft((current) => ({
...current,
name: active.name,
provider_kind: active.provider_kind,
model: active.model,
base_url: active.base_url ?? "",
disable_thinking: active.disable_thinking,
embedding_mode:
active.embedding_provider_kind === "local_onnx" ? "local" : "custom",
embedding_provider_kind:
active.embedding_provider_kind === "ollama"
? "ollama"
: "openai_compatible",
embedding_model: active.embedding_model ?? "",
embedding_base_url: active.embedding_base_url ?? "",
timeout_seconds: String(active.timeout_seconds),
max_concurrency: String(active.max_concurrency),
context_window:
active.context_window === null ? "" : String(active.context_window),
}));
setProbe({
state: "passed",
message: "当前模型已经启用,可以继续完成首次设置。",
});
}, [models.profiles, saved]);
useEffect(() => {
const finished = embeddingSwitch.current?.last_finished;
if (
!switchPendingJobId ||
!finished ||
finished.job_id !== switchPendingJobId
)
return;
if (finished.state === "done") {
flight.current = false;
setSwitchPendingJobId(null);
setPhase("idle");
onEnabled?.();
} else if (finished.state === "rolled_back") {
flight.current = false;
setSwitchPendingJobId(null);
setPhase("idle");
setError("索引更新没有完成,仍在使用原来的搜索模型。请检查连接后重试。");
}
}, [embeddingSwitch.current?.last_finished, onEnabled, switchPendingJobId]);
function edit(patch: Partial<OnboardingModelDraft>) {
if (flight.current) return;
draftTouched.current = true;
if (patch.provider_id && patch.provider_id !== draft.provider_id) {
const preset = PROVIDER_PRESETS.find(
(item) => item.id === patch.provider_id,
@@ -232,11 +291,27 @@ export function OnboardingModelController({
async function enable() {
if (flight.current || probe?.state !== "passed" || !saved) return;
if (saved.is_active) {
onEnabled?.();
return;
}
flight.current = true;
setPhase("enabling");
setError(null);
const trace = traceUiAction("memory", "submit");
try {
const plan = embeddingSwitch.shouldUseLegacyActivation()
? null
: await embeddingSwitch.planActivation(
saved.profile_id,
saved.revision,
);
if (!plan && !embeddingSwitch.shouldUseLegacyActivation())
throw new Error("embedding_switch_plan_unconfirmed");
if (plan?.needed) {
trace.done("succeeded", { reason: "embedding_switch_confirmation" });
return;
}
const active = await models.activate(saved.profile_id);
if (!active?.is_active) throw new Error("model_activation_unconfirmed");
trace.done("succeeded");
@@ -251,76 +326,102 @@ export function OnboardingModelController({
}
return (
<OnboardingModelStep
draft={draft}
providers={[
...PROVIDER_PRESETS.map((preset) => ({
id: preset.id,
label: preset.label,
keyRequired: preset.provider !== "ollama",
supported: true,
})),
{
id: "custom",
label: "自定义兼容服务",
keyRequired: true,
supported: true,
},
]}
advancedOpen={advanced}
onAdvancedChange={setAdvanced}
onDraftChange={edit}
phase={phase}
capability={{ saveAndEnable: true }}
canSaveAndEnable={probe?.state === "passed" && !!saved}
canTest={!models.pendingProbe && !models.probeStorageFailed}
canSaveDraft={!models.pendingCreation && !models.creationStorageFailed}
inputsLocked={flight.current}
fieldErrors={Object.fromEntries(
Object.entries(fields).map(([key, value]) => [
key,
value === "onboardingIntegerRange"
? t(
"onboardingIntegerRange",
key === "timeout_seconds"
? { min: 1, max: 600 }
: key === "max_concurrency"
? { min: 1, max: 32 }
: { min: 1024, max: 10_000_000 },
)
: text(value),
]),
)}
error={error ?? (models.error ? text(models.error) : null)}
probe={probe}
profile={
saved
? {
profile_id: saved.profile_id,
revision: saved.revision,
has_configured_api_key:
saved.has_configured_api_key ?? saved.has_api_key,
credential_state: saved.credential_state,
}
: null
}
sourceNames={sourceNames}
importedConversationCount={importedConversationCount}
connectionSummary={
connectionAlreadyApplied ? "所选连接已启用" : "本次未选择应用连接"
}
generationRecipient={draft.base_url || "尚未填写接收方"}
embeddingRecipient={
draft.embedding_mode === "local"
? "本机内置向量模型"
: draft.embedding_base_url || draft.base_url || "尚未填写接收方"
}
onSaveDraft={() => save(false)}
onTest={() => save(true)}
onSaveAndEnable={enable}
onBack={onBack}
onSkip={onSkip}
onActionError={() => setError("操作未确认,草稿已保留。")}
/>
<>
<OnboardingModelStep
draft={draft}
providers={[
...PROVIDER_PRESETS.map((preset) => ({
id: preset.id,
label: preset.label,
keyRequired: preset.provider !== "ollama",
supported: true,
})),
{
id: "custom",
label: "自定义兼容服务",
keyRequired: true,
supported: true,
},
]}
advancedOpen={advanced}
onAdvancedChange={setAdvanced}
onDraftChange={edit}
phase={phase}
capability={{ saveAndEnable: true }}
canSaveAndEnable={probe?.state === "passed" && !!saved}
enableActionLabel={saved?.is_active ? "继续使用当前模型" : undefined}
canTest={!models.pendingProbe && !models.probeStorageFailed}
canSaveDraft={!models.pendingCreation && !models.creationStorageFailed}
inputsLocked={flight.current}
fieldErrors={Object.fromEntries(
Object.entries(fields).map(([key, value]) => [
key,
value === "onboardingIntegerRange"
? t(
"onboardingIntegerRange",
key === "timeout_seconds"
? { min: 1, max: 600 }
: key === "max_concurrency"
? { min: 1, max: 32 }
: { min: 1024, max: 10_000_000 },
)
: text(value),
]),
)}
error={error ?? (models.error ? text(models.error) : null)}
probe={probe}
profile={
saved
? {
profile_id: saved.profile_id,
revision: saved.revision,
has_configured_api_key:
saved.has_configured_api_key ?? saved.has_api_key,
credential_state: saved.credential_state,
}
: null
}
sourceNames={sourceNames}
importedConversationCount={importedConversationCount}
connectionSummary={
connectionAlreadyApplied ? "所选连接已启用" : "本次未选择应用连接"
}
generationRecipient={draft.base_url || "尚未填写接收方"}
embeddingRecipient={
draft.embedding_mode === "local"
? "本机内置向量模型"
: draft.embedding_base_url || draft.base_url || "尚未填写接收方"
}
onSaveDraft={() => save(false)}
onTest={() => save(true)}
onSaveAndEnable={enable}
onBack={onBack}
onSkip={onSkip}
onActionError={() => setError("操作未确认,草稿已保留。")}
/>
{embeddingSwitch.plan?.needed && saved ? (
<EmbeddingSwitchConfirmDialog
plan={embeddingSwitch.plan}
busy={embeddingSwitch.busy === "start"}
errorCode={embeddingSwitch.errorCode}
onCancel={() => {
embeddingSwitch.clearPlan();
embeddingSwitch.clearError();
}}
onStart={() => {
flight.current = true;
setPhase("enabling");
void embeddingSwitch.startPlannedSwitch().then((job) => {
if (job) {
setSwitchPendingJobId(job.job_id);
return;
}
flight.current = false;
setPhase("idle");
});
}}
/>
) : null}
</>
);
}
+17 -2
View File
@@ -13,6 +13,7 @@ import { useGuiLanguage } from "./gui-language";
export interface OptionMenuItem {
value: string;
label: string;
description?: string;
disabled?: boolean;
lang?: string;
}
@@ -265,7 +266,14 @@ export function OptionMenu(props: {
}
}}
>
{visibleLabel}
{selected?.description ? (
<span className="om-option-menu-copy">
<span>{visibleLabel}</span>
<small>{selected.description}</small>
</span>
) : (
visibleLabel
)}
</button>
{open &&
createPortal(
@@ -298,7 +306,14 @@ export function OptionMenu(props: {
}}
onClick={() => choose(index)}
>
{item.label}
{item.description ? (
<span className="om-option-menu-copy">
<span>{item.label}</span>
<small>{item.description}</small>
</span>
) : (
item.label
)}
</button>
))}
</div>,
+95 -9
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import { cleanup, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { Ref } from "react";
import { afterEach, expect, it, vi } from "vitest";
@@ -27,7 +27,7 @@ it("changes settings copy live without rereading profiles or changing permission
}),
).toBeNull();
const reads = vi.mocked(getModelProfiles).mock.calls.length;
await user.click(screen.getByRole("combobox"));
await user.click(screen.getByRole("combobox", { name: "Idioma" }));
await user.click(screen.getByRole("option", { name: "简体中文" }));
expect(await screen.findByRole("heading", { name: "记忆模型" })).toBeTruthy();
expect(vi.mocked(getModelProfiles).mock.calls).toHaveLength(reads);
@@ -48,6 +48,36 @@ vi.mock("./api", () => ({
imports_unaffected: true,
}),
setSedimentation: vi.fn(),
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: {},
},
}),
setMemoryMode: vi.fn(),
getEvolveSchedule: vi.fn().mockResolvedValue({
enabled: true,
time: "03:00",
}),
setEvolveSchedule: vi.fn(),
getEvolveOverview: vi.fn().mockResolvedValue({
mode: "pro",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
}),
startEvolve: vi.fn(),
getMemoryProcessing: vi.fn().mockResolvedValue({
embedding_switch: null,
}),
apiErrorCode: vi.fn().mockReturnValue(null),
}));
vi.mock("./ModelSettings", async () => {
const { createElement, useImperativeHandle } = await import("react");
@@ -55,14 +85,25 @@ vi.mock("./ModelSettings", async () => {
// Stands in for the real component at its worst: every mutating action is
// fenced, and the only way out is the recovery entry that lives inside the
// same area the fence used to keep shut.
ModelSettings: ({ controlsRef }: { controlsRef?: Ref<unknown> }) => {
ModelSettings: ({
controlsRef,
focusSection,
}: {
controlsRef?: Ref<unknown>;
focusSection?: "generation" | "embedding";
}) => {
useImperativeHandle(controlsRef, () => ({
edit: () => false,
editProfile: () => false,
add: () => false,
test: () => Promise.resolve(undefined),
}));
return createElement("button", { type: "button" }, "核对测试结果");
return createElement(
"div",
null,
createElement("span", null, focusSection),
createElement("button", { type: "button" }, "核对测试结果"),
);
},
};
});
@@ -97,8 +138,8 @@ it("shows the help and diagnostics group by default and hides it when opted out"
} as NonNullable<typeof window.oneMemory>;
const user = userEvent.setup();
const view = render(<R6Settings onDiagnostics={vi.fn()} />);
expect(screen.getByRole("combobox")).toBeTruthy();
await user.click(screen.getByRole("combobox"));
expect(screen.getByRole("combobox", { name: "语言" })).toBeTruthy();
await user.click(screen.getByRole("combobox", { name: "语言" }));
expect(screen.getAllByRole("option")).toHaveLength(12);
await user.keyboard("{Escape}");
await user.click(screen.getByRole("button", { name: "连接其他应用" }));
@@ -151,14 +192,20 @@ it("reports a stop that did not confirm instead of showing it stopped", async ()
);
// The model configuration is a different store: a failed switch must not
// leave the page claiming the model was removed.
expect(screen.getByRole("heading", { name: "当前模型" })).toBeTruthy();
expect(screen.getByText("当前模型")).toBeTruthy();
});
it("opens the model area even when a pending probe fences the editor, so recovery stays reachable", async () => {
const user = userEvent.setup();
render(<R6Settings onDiagnostics={vi.fn()} />);
await user.click(await screen.findByRole("button", { name: "修改模型" }));
const generationLane = (
await screen.findByRole("heading", { name: "记忆整理" })
).closest(".r6-model-lane") as HTMLElement | null;
if (!generationLane) throw new Error("Missing generation lane");
await user.click(
within(generationLane).getByRole("button", { name: "配置" }),
);
// The fence is still reported: the editor really did refuse.
const alert = await screen.findByRole("alert");
@@ -173,9 +220,48 @@ it("reaches the same recovery entry when no model has been configured yet", asyn
const user = userEvent.setup();
render(<R6Settings onDiagnostics={vi.fn()} />);
await user.click(await screen.findByRole("button", { name: "配置模型" }));
const generationLane = (
await screen.findByRole("heading", { name: "记忆整理" })
).closest(".r6-model-lane") as HTMLElement | null;
if (!generationLane) throw new Error("Missing generation lane");
await user.click(
within(generationLane).getByRole("button", { name: "配置模型" }),
);
expect(screen.getByRole("button", { name: "核对测试结果" })).toBeTruthy();
const searchLane = screen
.getByRole("heading", { name: "记忆搜索" })
.closest(".r6-model-lane") as HTMLElement | null;
if (!searchLane) throw new Error("Missing search lane");
expect(
within(searchLane).getByRole("button", { name: "配置" }),
).toHaveProperty("disabled", true);
expect(searchLane.textContent).toContain("配置记忆整理后可自定义");
});
it("shows separate compact lanes and opens the requested presentation", async () => {
const user = userEvent.setup();
render(<R6Settings onDiagnostics={vi.fn()} />);
const generationLane = (
await screen.findByRole("heading", { name: "记忆整理" })
).closest(".r6-model-lane") as HTMLElement | null;
const embeddingLane = screen
.getByRole("heading", { name: "记忆搜索" })
.closest(".r6-model-lane") as HTMLElement | null;
if (!generationLane || !embeddingLane) throw new Error("Missing model lane");
expect(generationLane.textContent).toContain("当前模型");
expect(generationLane.textContent).toContain("LLM");
expect(embeddingLane.textContent).toContain("本地内置(推荐)");
expect(embeddingLane.textContent).toContain("Embedding");
await user.click(within(embeddingLane).getByRole("button", { name: "配置" }));
expect(screen.getByText("embedding")).toBeTruthy();
await user.click(
within(generationLane).getByRole("button", { name: "配置" }),
);
expect(screen.getByText("generation")).toBeTruthy();
});
it("leaves the switch unusable when the state cannot be read", async () => {
+144 -84
View File
@@ -1,4 +1,4 @@
import type { ModelProfile } from "@one-memory/contracts";
import type { MemoryPipelineMode, ModelProfile } from "@one-memory/contracts";
import { Download, RefreshCw } from "lucide-react";
import {
useCallback,
@@ -12,7 +12,12 @@ import { getModelProfiles, setSedimentation } from "./api";
import { ConnectionFallback } from "./ConnectionFallback";
import { exportDiagnosticsFile } from "./diagnostics-export";
import { LanguageSelector } from "./LanguageSelector";
import { ModelSettings, type ModelSettingsActions } from "./ModelSettings";
import { MemoryModeSettings } from "./MemoryModeSettings";
import {
ModelSettings,
type ModelSettingsActions,
type ModelSettingsFocusSection,
} from "./ModelSettings";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button, R6Switch } from "./R6Controls";
import {
@@ -30,7 +35,7 @@ import "./r6-settings.css";
type Group = "model" | "connections" | "help";
const labels: Record<Group, string> = {
model: "模型",
model: "记忆模式与模型",
connections: "连接其他应用",
help: "帮助与诊断",
};
@@ -67,6 +72,11 @@ export function R6Settings({
const [configurationRefusal, setConfigurationRefusal] = useState(false);
const [modelVersion, setModelVersion] = useState(0);
const [configurationOpen, setConfigurationOpen] = useState(false);
const [configurationFocus, setConfigurationFocus] =
useState<ModelSettingsFocusSection>("generation");
const [memoryMode, setMemoryModeState] = useState<MemoryPipelineMode | null>(
null,
);
const [quitOpen, setQuitOpen] = useState(false);
const [quitting, setQuitting] = useState(false);
const [quitError, setQuitError] = useState("");
@@ -208,8 +218,11 @@ export function R6Settings({
}
}
function openConfiguration() {
function openConfiguration(
focusSection: ModelSettingsFocusSection = "generation",
) {
setConfigurationRefusal(false);
setConfigurationFocus(focusSection);
const opened = currentModel
? modelActions.current?.editProfile(currentModel)
: modelActions.current?.add();
@@ -292,95 +305,141 @@ export function R6Settings({
hidden={group !== "model"}
className="r6-settings-model"
>
<h2>{text("记忆模型")}</h2>
<MemoryModeSettings
hasActiveModel={currentModel !== null}
modelStateKnown={profiles !== null && !modelError}
onConfigureModel={() => openConfiguration("generation")}
onModeChange={setMemoryModeState}
/>
<div className="r6-model-heading">
<div>
<h2 className="r6-model-title">{text("记忆模型")}</h2>
<p>{text("分别管理记忆整理与搜索所使用的模型。")}</p>
</div>
<R6Button
variant="quiet"
aria-label={text("刷新模型状态")}
title={text("刷新模型状态")}
onClick={() => setModelVersion((value) => value + 1)}
>
<RefreshCw size={16} aria-hidden="true" />
</R6Button>
</div>
<div className="r6-model-overview">
<p className="r6-model-caption">{text("当前模型")}</p>
<h3 className="r6-current-model">
{currentModel?.name ??
text(
profiles === null
? "正在读取模型…"
: modelError
? "模型状态暂不可读"
: "尚未配置模型",
<div className="r6-model-lane">
<div className="r6-model-lane-copy">
<h3>{text("记忆整理")}</h3>
<p>{text("把会话理解并整理成可长期使用的记忆。")}</p>
<strong>
{currentModel?.name ??
text(
profiles === null
? "正在读取模型…"
: modelError
? "状态暂不可读"
: "尚未配置",
)}
</strong>
{currentModel && (
<small>
LLM · {currentModel.model} ·{" "}
{text(safeEndpoint(currentModel.base_url))}
</small>
)}
</h3>
{currentModel && (
<p className="r6-model-state">
{currentModel.model} ·{" "}
{text(safeEndpoint(currentModel.base_url))}
</p>
)}
<div className="r6-model-actions">
<R6Button
variant="primary"
disabled={profiles === null}
onClick={openConfiguration}
>
{text(currentModel ? "修改模型" : "配置模型")}
</R6Button>
</div>
<R6Button
variant="quiet"
aria-label={text("刷新模型状态")}
title={text("刷新模型状态")}
onClick={() => setModelVersion((value) => value + 1)}
disabled={profiles === null}
onClick={() => openConfiguration("generation")}
>
<RefreshCw size={16} aria-hidden="true" />
{text(currentModel ? "配置" : "配置模型")}
</R6Button>
</div>
{modelError && (
<p role="alert" className="r6-settings-error">
{text(
profiles === null
? "模型状态未能读取,请重试。"
: "模型状态刷新未成功,仍显示上次确认的模型状态;请重试。",
)}
</p>
)}
{configurationRefusal && (
<p role="alert" className="r6-settings-error">
{text(
"上次模型测试或创建还待核对,配置暂时不能改动。请先在下方模型配置区核对结果; 现有配置和原文不会被改动。",
)}
</p>
)}
{currentModel && (
<div className="r6-setting-row">
<div>
<h3>{text("用模型沉淀记忆")}</h3>
<p>
{text(
"开启后,导入和同步到本机的会话会交给当前模型整理成记忆,会产生模型调用。 停止后不再调用模型、不再整理记忆,但会话仍会照常导入和保存;已排队的内容会留着,重新开启后继续。 模型配置始终保留,重新开启不需要重新填写。",
)}
</p>
</div>
<R6Switch
label={text("用模型沉淀记忆")}
checked={sedimentation === true}
disabled={
sedimentation === null ||
sedimentationPending ||
sedimentationUnavailable
}
onCheckedChange={(enabled) =>
void changeSedimentation(enabled)
}
/>
<div className="r6-model-lane">
<div className="r6-model-lane-copy">
<h3>{text("记忆搜索")}</h3>
<p>{text("在需要时找到与你的问题最相关的记忆。")}</p>
<strong>
{text(
currentModel?.embedding_provider_kind &&
currentModel.embedding_provider_kind !== "local_onnx" &&
currentModel.embedding_model
? currentModel.embedding_model
: "本地内置(推荐)",
)}
</strong>
<small>
{text(
currentModel?.embedding_provider_kind &&
currentModel.embedding_provider_kind !== "local_onnx"
? `Embedding · ${currentModel.embedding_provider_kind}`
: profiles !== null && !currentModel
? "Embedding · 配置记忆整理后可自定义"
: "Embedding · 本机处理,无需额外配置",
)}
</small>
</div>
)}
{sedimentationError && (
<p role="alert" className="r6-settings-error">
{text(sedimentationError)}
</p>
)}
{sedimentationUnavailable && (
<p role="alert" className="r6-settings-error">
{text(
"当前安装还不能读取这个开关,请确认后台服务已启动后重试。",
)}
</p>
)}
<R6Button
variant="quiet"
disabled={profiles === null || !currentModel}
onClick={() => openConfiguration("embedding")}
>
{text("配置")}
</R6Button>
</div>
</div>
{modelError && (
<p role="alert" className="r6-settings-error">
{text(
profiles === null
? "模型状态未能读取,请重试。"
: "模型状态刷新未成功,仍显示上次确认的模型状态;请重试。",
)}
</p>
)}
{configurationRefusal && (
<p role="alert" className="r6-settings-error">
{text(
"上次模型测试或创建还待核对,配置暂时不能改动。请先在下方模型配置区核对结果; 现有配置和原文不会被改动。",
)}
</p>
)}
{currentModel && memoryMode !== "lite" && (
<div className="r6-setting-row">
<div>
<h3>{text("用模型沉淀记忆")}</h3>
<p>
{text(
"开启后,导入和同步到本机的会话会交给当前模型整理成记忆,会产生模型调用。 停止后不再调用模型、不再整理记忆,但会话仍会照常导入和保存;已排队的内容会留着,重新开启后继续。 模型配置始终保留,重新开启不需要重新填写。",
)}
</p>
</div>
<R6Switch
label={text("用模型沉淀记忆")}
checked={sedimentation === true}
disabled={
sedimentation === null ||
sedimentationPending ||
sedimentationUnavailable
}
onCheckedChange={(enabled) =>
void changeSedimentation(enabled)
}
/>
</div>
)}
{sedimentationError && (
<p role="alert" className="r6-settings-error">
{text(sedimentationError)}
</p>
)}
{sedimentationUnavailable && (
<p role="alert" className="r6-settings-error">
{text(
"当前安装还不能读取这个开关,请确认后台服务已启动后重试。",
)}
</p>
)}
<section
className="r6-model-owner"
hidden={!configurationOpen}
@@ -388,6 +447,7 @@ export function R6Settings({
>
<ModelSettings
compactSingle
focusSection={configurationFocus}
controlsRef={modelActions}
onProfilesChanged={() => setModelVersion((value) => value + 1)}
onSetupComplete={() => setConfigurationOpen(false)}
+156 -1
View File
@@ -1,5 +1,11 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import {
act,
cleanup,
fireEvent,
render,
screen,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
@@ -15,6 +21,8 @@ import { SdkRawHistory } from "./SdkRawHistory";
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
vi.useRealTimers();
});
it("localizes controls without translating original conversation fields", async () => {
@@ -87,3 +95,150 @@ it("lists and searches SDK raw without legacy conversation APIs", async () => {
expect(await screen.findByText("搜索结果")).toBeTruthy();
expect(searchSdkRaw).toHaveBeenCalledWith("查询");
});
it("does not call a zero-result search an empty conversation history", async () => {
getSdkRaw.mockResolvedValue({
items: [
{
raw_id: "raw-existing",
content: "已有原始对话",
session_id: "session-existing",
source: "cursor",
memory_at: null,
},
],
total: 1,
offset: 0,
limit: 100,
});
searchSdkRaw.mockResolvedValue([]);
render(<SdkRawHistory />);
await userEvent.type(screen.getByLabelText("搜索原始对话"), "没有命中");
await userEvent.click(screen.getByRole("button", { name: "搜索" }));
expect(await screen.findByText("找到 0 条")).toBeTruthy();
expect(
screen.queryByText("还没有原始对话。连接来源后,对话会显示在这里。"),
).toBeNull();
});
it("retries the lite history read in place after a load failure", async () => {
getSdkRaw.mockRejectedValueOnce(new Error("offline")).mockResolvedValueOnce({
items: [
{
raw_id: "raw-recovered",
content: "恢复后的原始对话",
session_id: "session-recovered",
source: "cursor",
memory_at: null,
},
],
total: 1,
offset: 0,
limit: 100,
});
render(<SdkRawHistory />);
await userEvent.click(await screen.findByRole("button", { name: "重试" }));
expect(await screen.findByText("恢复后的原始对话")).toBeTruthy();
expect(getSdkRaw).toHaveBeenCalledTimes(2);
});
it("retries the failed raw search instead of reloading history", async () => {
getSdkRaw.mockResolvedValue({
items: [],
total: 0,
offset: 0,
limit: 100,
});
searchSdkRaw
.mockRejectedValueOnce(new Error("offline"))
.mockResolvedValueOnce([
{
kind: "raw",
raw_id: "raw-search-recovered",
content: "重试后的搜索结果",
source: "cursor",
session_id: "session-search",
memory_at: null,
score: 0.8,
truncated: false,
},
]);
render(<SdkRawHistory />);
await userEvent.type(screen.getByLabelText("搜索原始对话"), "恢复搜索");
await userEvent.click(screen.getByRole("button", { name: "搜索" }));
await userEvent.click(await screen.findByRole("button", { name: "重试" }));
expect(await screen.findByText("重试后的搜索结果")).toBeTruthy();
expect(searchSdkRaw).toHaveBeenCalledTimes(2);
expect(getSdkRaw).toHaveBeenCalledTimes(1);
});
it("does not let a history poll clear an independent search failure", async () => {
vi.useFakeTimers();
getSdkRaw.mockResolvedValue({
items: [
{
raw_id: "raw-old",
content: "已有历史",
session_id: "session-old",
source: "cursor",
memory_at: null,
},
],
total: 1,
offset: 0,
limit: 100,
});
searchSdkRaw.mockRejectedValue(new Error("search offline"));
render(<SdkRawHistory />);
await act(async () => Promise.resolve());
fireEvent.change(screen.getByLabelText("搜索原始对话"), {
target: { value: "失败查询" },
});
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "搜索" }));
await Promise.resolve();
});
expect(screen.getByRole("alert").textContent).toContain("原始对话搜索失败");
await act(async () => {
await vi.advanceTimersByTimeAsync(12_000);
});
expect(screen.getByRole("alert").textContent).toContain("原始对话搜索失败");
});
it("ignores a stale initial-history failure after a newer search succeeds", async () => {
let rejectHistory!: (reason: Error) => void;
getSdkRaw.mockReturnValueOnce(
new Promise((_resolve, reject) => {
rejectHistory = reject;
}),
);
searchSdkRaw.mockResolvedValue([
{
kind: "raw",
raw_id: "raw-new-search",
content: "较新的搜索结果",
source: "cursor",
session_id: "session-new",
memory_at: null,
score: 0.9,
truncated: false,
},
]);
render(<SdkRawHistory />);
await userEvent.type(screen.getByLabelText("搜索原始对话"), "新查询");
await userEvent.click(screen.getByRole("button", { name: "搜索" }));
expect(await screen.findByText("较新的搜索结果")).toBeTruthy();
await act(async () => rejectHistory(new Error("late history failure")));
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.getByText("较新的搜索结果")).toBeTruthy();
});
+76 -20
View File
@@ -2,8 +2,9 @@ import type { SdkRawContextItem, SdkRawRecord } from "@one-memory/contracts";
import { type FormEvent, useEffect, useRef, useState } from "react";
import { getSdkRaw, searchSdkRaw } from "./api";
import { useGuiLanguage } from "./gui-language";
import { subscribePoll } from "./poll-scheduler";
export function SdkRawHistory() {
export function SdkRawHistory({ active = true }: { active?: boolean }) {
const { locale, t, tn } = useGuiLanguage();
const translation = useRef(t);
translation.current = t;
@@ -11,35 +12,67 @@ export function SdkRawHistory() {
const [results, setResults] = useState<SdkRawContextItem[] | null>(null);
const [query, setQuery] = useState("");
const [error, setError] = useState("");
const [errorKind, setErrorKind] = useState<"load" | "search" | null>(null);
const errorKindRef = useRef<"load" | "search" | null>(null);
const searchEpoch = useRef(0);
const [busy, setBusy] = useState(false);
const [revision, setRevision] = useState(0);
// biome-ignore lint/correctness/useExhaustiveDependencies: revision is the explicit retry trigger.
useEffect(() => {
if (!active) return;
let current = true;
void getSdkRaw({ limit: 100 }).then(
(page) => {
if (current) setRecent(page.items);
},
() => {
if (current) setError(translation.current("sdk.raw.loadFailed"));
},
);
const startedBeforeSearch = searchEpoch.current;
const consume = (value: unknown) => {
if (!current) return;
setRecent((value as Awaited<ReturnType<typeof getSdkRaw>>).items);
if (errorKindRef.current !== "search") {
errorKindRef.current = null;
setError("");
setErrorKind(null);
}
};
void getSdkRaw({ limit: 100 }).then(consume, () => {
if (current && startedBeforeSearch === searchEpoch.current) {
setError(translation.current("sdk.raw.loadFailed"));
errorKindRef.current = "load";
setErrorKind("load");
}
});
const stop = subscribePoll({
key: "sdk-raw-history",
intervalMs: 12_000,
fetch: () => getSdkRaw({ limit: 100 }),
onValue: consume,
});
return () => {
current = false;
stop();
};
}, []);
}, [active, revision]);
async function runSearch(value: string) {
searchEpoch.current += 1;
setBusy(true);
setResults(null);
setError("");
errorKindRef.current = null;
setErrorKind(null);
try {
setResults(await searchSdkRaw(value));
} catch {
setError(t("sdk.raw.searchFailed"));
errorKindRef.current = "search";
setErrorKind("search");
} finally {
setBusy(false);
}
}
async function submit(event: FormEvent) {
event.preventDefault();
if (!query.trim()) return;
setBusy(true);
setError("");
try {
setResults(await searchSdkRaw(query.trim()));
} catch {
setError(t("sdk.raw.searchFailed"));
} finally {
setBusy(false);
}
await runSearch(query.trim());
}
const items = results ?? recent.map(asContextItem);
@@ -57,12 +90,35 @@ export function SdkRawHistory() {
{busy ? t("sdk.raw.searching") : t("sdk.raw.search")}
</button>
</form>
{error ? <p role="alert">{error}</p> : null}
{error ? (
<p role="alert">
{error}{" "}
<button
type="button"
disabled={busy}
onClick={() => {
if (errorKind === "search" && query.trim()) {
void runSearch(query.trim());
} else {
setError("");
errorKindRef.current = null;
setErrorKind(null);
setRevision((value) => value + 1);
}
}}
>
{t("connectionFallback.retry")}
</button>
</p>
) : null}
<p aria-live="polite">
{results === null
? t("sdk.raw.recent")
: tn("sdk.raw.found", items.length)}
</p>
{!error && results === null && items.length === 0 ? (
<p>{t("sdk.raw.empty")}</p>
) : null}
<ol>
{items.map((item) => (
<li key={item.raw_id}>
+67
View File
@@ -0,0 +1,67 @@
import type {
SdkRawContextItem,
SdkRawRecord,
SourceResponse,
} from "@one-memory/contracts";
import { clockStamp } from "./clock-stamp";
import { useGuiLanguage } from "./gui-language";
import { rawRecordTime } from "./sdk-raw-map";
import { sourceNameOr, UNATTRIBUTED_SOURCE_ID } from "./source-label";
type RawRecord = SdkRawRecord | SdkRawContextItem;
function authorLabel(role: string, t: ReturnType<typeof useGuiLanguage>["t"]) {
if (role === "user") return t("selectedMemory.author.user");
if (role === "assistant" || role === "agent")
return t("selectedMemory.author.agent");
return t("selectedMemory.author.unknown");
}
export function SelectedRawDetail({
record,
sources,
}: {
record: RawRecord;
sources: SourceResponse[];
}) {
const { locale, t } = useGuiLanguage();
const messages =
"messages" in record && record.messages?.length
? record.messages
: [
{
role:
"role" in record && typeof record.role === "string"
? record.role
: "unknown",
content: record.content,
},
];
const source =
record.source === UNATTRIBUTED_SOURCE_ID
? t("selectedMemory.sourceUnattributed")
: sourceNameOr(
sources,
record.source,
record.source || t("selectedMemory.sourceUnknown"),
);
return (
<article className="map-dialogue-reading">
<p className="selected-memory-status">{t("memory:原始对话")}</p>
<ul className="map-transcript">
{messages.map((message) => (
<li key={`${record.raw_id}:${message.role}:${message.content}`}>
<small>{authorLabel(message.role, t)}</small>
<p>{message.content}</p>
</li>
))}
</ul>
<p className="selected-memory-source">
{t("selectedMemory.sourceLine", {
source,
time: clockStamp(rawRecordTime(record), locale),
})}
</p>
</article>
);
}
@@ -0,0 +1,242 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
const fake = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
del: vi.fn(),
}));
vi.mock("axios", () => ({
default: {
create: () => ({
get: fake.get,
post: fake.post,
delete: fake.del,
interceptors: { request: { use: vi.fn() } },
}),
isAxiosError: (value: unknown) =>
typeof value === "object" && value !== null && "isAxiosError" in value,
},
}));
import {
cancelEmbeddingSwitch,
deleteRetiredEmbeddingSpace,
embeddingSwitchRefusal,
embeddingSwitchUnsupported,
getCurrentEmbeddingSwitch,
listEmbeddingSpaces,
planEmbeddingSwitch,
startEmbeddingSwitch,
} from "./api";
const NOW = "2026-11-03T09:00:00Z";
const summary = (space_id: string | null) => ({
space_id,
model: "embed",
dims: 512,
collection_base: space_id ? "memory" : "",
vectors: space_id ? 1 : null,
});
const plan = {
needed: true,
reason: "embedding_identity_changed",
plan_token: "token",
source: summary("space-1"),
target: summary(null),
target_profile_id: "profile-1",
target_revision: 2,
target_provider_kind: "openai_compatible",
target_local: false,
sources: [
{
space_id: "space-1",
collection_base: "memory",
dims: 512,
vectors: 1,
unmerged: null,
},
],
total_vectors: 1,
users: 1,
graph_vectors: null,
estimate_s: 1,
requests: 1,
disk_bytes_needed: 1,
disk_bytes_free: 2,
retention_days: 14,
blockers: [],
} as const;
const job = {
job_id: "job-1",
kind: "switch",
state: "preparing",
source: summary("space-1"),
target: summary("space-2"),
stray_space_ids: [],
target_profile_id: "profile-1",
target_revision: 2,
requested_by: "app",
progress: {
phase: "prepare",
done: 0,
total: 1,
percent: 0,
embedded: 0,
reused: 0,
kept: 0,
role: null,
source_index: 0,
sources: 1,
rate_per_s: null,
eta_s: null,
updated_at: NOW,
},
cancel_requested: false,
error_code: null,
attempt: 1,
created_at: NOW,
updated_at: NOW,
finished_at: null,
} as const;
const space = {
space_id: "space-1",
state: "retired",
model: "embed",
dims: 512,
name_dims: 512,
collection_base: "memory",
naming: "legacy",
graph_dir: "graph",
provider_kind: "local_onnx",
profile_id: null,
profile_revision: null,
vectors: 1,
created_at: NOW,
live_since: NOW,
retired_at: NOW,
keep_until: NOW,
dropped_at: null,
} as const;
beforeEach(() => vi.resetAllMocks());
describe("embedding switch API", () => {
it("plans and starts through validated request bodies", async () => {
fake.post
.mockResolvedValueOnce({ data: plan })
.mockResolvedValueOnce({ data: job });
await expect(planEmbeddingSwitch("profile-1", 2)).resolves.toEqual(plan);
await expect(
startEmbeddingSwitch({
profile_id: "profile-1",
expected_revision: 2,
plan_token: "token",
requested_by: "app",
}),
).resolves.toEqual(job);
expect(fake.post.mock.calls[0].slice(0, 2)).toEqual([
"/api/v1/embedding-switches/plan",
{ profile_id: "profile-1", expected_revision: 2 },
]);
expect(fake.post.mock.calls[1][0]).toBe("/api/v1/embedding-switches");
});
it("reads, cancels, lists and deletes with parsed receipts", async () => {
const current = {
schema_version: 1,
phase: "running",
writes_paused: true,
scan_state: "done",
scan_error: null,
job,
last_finished: null,
};
const spaces = {
schema_version: 1,
live_space_id: null,
spaces: [space],
scanned: null,
scan_error: null,
};
fake.get
.mockResolvedValueOnce({ data: current })
.mockResolvedValueOnce({ data: spaces });
fake.post.mockResolvedValueOnce({
data: { ...job, cancel_requested: true },
});
fake.del.mockResolvedValueOnce({
data: {
schema_version: 1,
space: { ...space, state: "dropped", dropped_at: NOW },
},
});
await expect(getCurrentEmbeddingSwitch()).resolves.toEqual(current);
await expect(cancelEmbeddingSwitch("job-1")).resolves.toMatchObject({
cancel_requested: true,
});
await expect(listEmbeddingSpaces()).resolves.toEqual(spaces);
await expect(deleteRetiredEmbeddingSpace("space-1")).resolves.toMatchObject(
{ space: { state: "dropped" } },
);
expect(fake.post.mock.calls[0][0]).toBe(
"/api/v1/embedding-switches/job-1/cancel",
);
expect(fake.del.mock.calls[0][0]).toBe("/api/v1/embedding-spaces/space-1");
});
it("keeps a replacement plan attached to a refusal", async () => {
const refusal = Object.assign(new Error("conflict"), {
isAxiosError: true,
response: {
status: 409,
data: { detail: "embedding_switch_plan_stale", plan },
},
});
fake.post.mockRejectedValueOnce(refusal);
let caught: unknown;
try {
await planEmbeddingSwitch("profile-1", 2);
} catch (reason) {
caught = reason;
}
expect(embeddingSwitchRefusal(caught)).toEqual({
code: "embedding_switch_plan_stale",
plan,
});
});
it("rejects malformed success bodies", async () => {
fake.get.mockResolvedValueOnce({
data: {
schema_version: 1,
phase: "running",
writes_paused: true,
scan_state: "done",
scan_error: null,
job: { ...job, state: "surprise" },
last_finished: null,
},
});
await expect(getCurrentEmbeddingSwitch()).rejects.toThrow(
"embedding_switch_current_invalid",
);
});
it("distinguishes an older Core route 404 from a product refusal", () => {
const routeMissing = {
isAxiosError: true,
response: { status: 404, data: { detail: "Not Found" } },
};
const productMissing = {
isAxiosError: true,
response: {
status: 404,
data: { detail: "embedding_switch_not_found" },
},
};
expect(embeddingSwitchUnsupported(routeMissing)).toBe(true);
expect(embeddingSwitchUnsupported(productMissing)).toBe(false);
});
});
+215 -14
View File
@@ -2,6 +2,14 @@ import {
CORE_SERVICE,
type ConnectSourcesResponse,
type DetectionResponse,
type EmbeddingSpaceDeleteReceipt,
type EmbeddingSpaces,
type EmbeddingSwitchCurrent,
type EmbeddingSwitchJob,
type EmbeddingSwitchPlan,
type EvolveOverview,
type EvolveRunReceipt,
type EvolveSchedule,
type ExtractionSettings,
type ExtractionWindowPreset,
type GraphWidthDecision,
@@ -17,6 +25,9 @@ import {
type MemoryAddInput,
type MemoryAddReceipt,
type MemoryGraphResponse,
type MemoryModeChange,
type MemoryModeState,
type MemoryPipelineMode,
type MemoryProcessingSummary,
type ModelCreationOperation,
type ModelProbeOperationReceipt,
@@ -42,20 +53,35 @@ import {
// stays here is the transport adapter: axios wiring, recovery clients, and the
// UI-facing error mapping.
import {
buildEmbeddingSwitchPlanRequest,
buildEmbeddingSwitchStartRequest,
buildMemoryAddRequest,
buildModelProfileCreateRequest,
buildModelProfileUpdateRequest,
buildSourceImportRequest,
buildSourceSyncRequest,
classifyModelRemove,
EMBEDDING_SPACES_PATH,
EMBEDDING_SWITCH_CURRENT_PATH,
type EmbeddingSwitchRefusal,
type EmbeddingSwitchStartInput,
embeddingSwitchCancelPath,
hasSourceSyncTarget,
memoryAddStatusPath,
normalizeManualExtractEvidence,
parseEmbeddingSpaceDeleteReceipt,
parseEmbeddingSpaces,
parseEmbeddingSwitchCurrent,
parseEmbeddingSwitchJob,
parseEmbeddingSwitchPlan,
parseEmbeddingSwitchRefusal,
parseEmbeddingSwitchSummary,
parseExtractionSettingsReceipt,
parseGraphWidthDecisionReceipt,
parseManualExtractReceipt,
parseMemoryAddReceipt,
parseModelProfileReceipt,
retiredEmbeddingSpacePath,
SourceSyncInputError,
} from "@one-memory/operations";
import axios from "axios";
@@ -588,6 +614,135 @@ export async function probeModelProfile(
});
}
function preserveEmbeddingSwitchRefusal(reason: unknown): never {
const refusal = embeddingSwitchRefusal(reason);
if (refusal !== null && typeof reason === "object" && reason !== null) {
throw Object.assign(reason, {
embeddingSwitchRefusal: refusal,
});
}
throw reason;
}
/** An older Core answers a route-level 404 because switch v1 is not present. */
export function embeddingSwitchUnsupported(reason: unknown): boolean {
return (
axios.isAxiosError(reason) &&
reason.response?.status === 404 &&
embeddingSwitchRefusal(reason) === null
);
}
/** Recover a Core refusal without losing the replacement plan it may carry. */
export function embeddingSwitchRefusal(
reason: unknown,
): EmbeddingSwitchRefusal | null {
if (
typeof reason === "object" &&
reason !== null &&
"embeddingSwitchRefusal" in reason
) {
const preserved = (reason as { embeddingSwitchRefusal?: unknown })
.embeddingSwitchRefusal;
if (
preserved !== null &&
typeof preserved === "object" &&
"code" in preserved
)
return preserved as EmbeddingSwitchRefusal;
}
if (!axios.isAxiosError(reason)) return null;
return parseEmbeddingSwitchRefusal(reason.response?.data);
}
export async function planEmbeddingSwitch(
profileId: string,
expectedRevision?: number,
traceId?: string,
): Promise<EmbeddingSwitchPlan> {
const request = buildEmbeddingSwitchPlanRequest(profileId, expectedRevision);
try {
const response = await client.post<unknown>(
request.path,
request.body,
traceId ? { headers: { "X-One-Memory-Trace-Id": traceId } } : undefined,
);
const plan = parseEmbeddingSwitchPlan(response.data);
if (plan === null) throw new Error("embedding_switch_plan_invalid");
return plan;
} catch (reason) {
preserveEmbeddingSwitchRefusal(reason);
}
}
export async function startEmbeddingSwitch(
input: EmbeddingSwitchStartInput,
traceId?: string,
): Promise<EmbeddingSwitchJob> {
const request = buildEmbeddingSwitchStartRequest(input);
try {
const response = await client.post<unknown>(
request.path,
request.body,
traceId ? { headers: { "X-One-Memory-Trace-Id": traceId } } : undefined,
);
const job = parseEmbeddingSwitchJob(response.data);
if (job === null) throw new Error("embedding_switch_job_invalid");
return job;
} catch (reason) {
preserveEmbeddingSwitchRefusal(reason);
}
}
export async function getCurrentEmbeddingSwitch(
options: { signal?: AbortSignal } = {},
): Promise<EmbeddingSwitchCurrent> {
const response = await client.get<unknown>(EMBEDDING_SWITCH_CURRENT_PATH, {
signal: options.signal,
});
const current = parseEmbeddingSwitchCurrent(response.data);
if (current === null) throw new Error("embedding_switch_current_invalid");
return current;
}
export async function cancelEmbeddingSwitch(
jobId: string,
traceId?: string,
): Promise<EmbeddingSwitchJob> {
const response = await client.post<unknown>(
embeddingSwitchCancelPath(jobId),
{},
traceId ? { headers: { "X-One-Memory-Trace-Id": traceId } } : undefined,
);
const job = parseEmbeddingSwitchJob(response.data);
if (job === null) throw new Error("embedding_switch_job_invalid");
return job;
}
export async function listEmbeddingSpaces(
options: { signal?: AbortSignal } = {},
): Promise<EmbeddingSpaces> {
const response = await client.get<unknown>(EMBEDDING_SPACES_PATH, {
signal: options.signal,
});
const spaces = parseEmbeddingSpaces(response.data);
if (spaces === null) throw new Error("embedding_spaces_invalid");
return spaces;
}
export async function deleteRetiredEmbeddingSpace(
spaceId: string,
traceId?: string,
): Promise<EmbeddingSpaceDeleteReceipt> {
const response = await client.delete<unknown>(
retiredEmbeddingSpacePath(spaceId),
traceId ? { headers: { "X-One-Memory-Trace-Id": traceId } } : undefined,
);
const receipt = parseEmbeddingSpaceDeleteReceipt(response.data);
if (receipt === null) throw new Error("embedding_space_receipt_invalid");
return receipt;
}
export async function activateModelProfile(
profileId: string,
traceId?: string,
@@ -789,30 +944,34 @@ export async function getSdkRaw({
source,
offset = 0,
limit = 100,
signal,
}: {
source?: string;
offset?: number;
limit?: number;
signal?: AbortSignal;
} = {}): Promise<SdkRawPage> {
const response = await client.get<SdkRawPage>("/api/v2/raw", {
params: { source, offset, limit },
signal,
});
return response.data;
}
export async function searchSdkRaw(
query: string,
signal?: AbortSignal,
options: { source?: string; signal?: AbortSignal } = {},
): Promise<SdkRawContextItem[]> {
const response = await client.post(
"/api/v2/context/search",
{
query,
kinds: ["raw"],
...(options.source ? { include_sources: [options.source] } : {}),
limit: 20,
character_budget: 12_000,
},
{ signal, timeout: 10_000 },
{ signal: options.signal, timeout: 10_000 },
);
const items = response.data?.items;
return Array.isArray(items)
@@ -907,6 +1066,56 @@ export async function getMemoryProcessing(): Promise<MemoryProcessingSummary> {
return response.data;
}
export async function getMemoryMode(): Promise<MemoryModeState> {
const response = await client.get<MemoryModeState>(
"/api/v1/settings/memory-mode",
);
return response.data;
}
export async function setMemoryMode(
mode: MemoryPipelineMode,
): Promise<MemoryModeChange> {
const response = await client.put<MemoryModeChange>(
"/api/v1/settings/memory-mode",
{ mode },
);
return response.data;
}
export async function getEvolveSchedule(): Promise<EvolveSchedule> {
const response = await client.get<EvolveSchedule>(
"/api/v1/settings/evolve-schedule",
);
return response.data;
}
export async function setEvolveSchedule(
update: Partial<EvolveSchedule>,
): Promise<EvolveSchedule> {
const response = await client.put<EvolveSchedule>(
"/api/v1/settings/evolve-schedule",
update,
);
return response.data;
}
export async function getEvolveOverview(
userId = "local",
): Promise<EvolveOverview> {
const response = await client.get<EvolveOverview>("/api/v2/evolve", {
params: { user_id: userId },
});
return response.data;
}
export async function startEvolve(userId = "local"): Promise<EvolveRunReceipt> {
const response = await client.post<EvolveRunReceipt>("/api/v2/evolve", {
user_id: userId,
});
return response.data;
}
export async function getDetailedLogging(): Promise<boolean> {
const response = await client.get("/api/v1/diagnostics/settings");
const value = response.data as Record<string, unknown>;
@@ -1001,17 +1210,6 @@ export async function getModelUsage(
return value;
}
export async function retryUnknownMemoryProcessing(
traceId?: string,
): Promise<{ requeued: number }> {
const response = await client.post<{ reconciled: number; requeued: number }>(
"/api/v1/memory-processing/retry-unknown",
{ confirm: true },
traceId ? { headers: { "X-One-Memory-Trace-Id": traceId } } : undefined,
);
return response.data;
}
export async function getExtractionSettings(): Promise<ExtractionSettings> {
const response = await client.get<ExtractionSettings>(
"/api/v1/extraction-settings",
@@ -1164,7 +1362,10 @@ export function isHealthResponse(value: unknown): value is HealthResponse {
(candidate.memory_backfill === undefined ||
candidate.memory_backfill === "running" ||
candidate.memory_backfill === "done" ||
candidate.memory_backfill === "failed")
candidate.memory_backfill === "failed") &&
(candidate.embedding_switch === undefined ||
candidate.embedding_switch === null ||
parseEmbeddingSwitchSummary(candidate.embedding_switch) !== null)
);
}
+271
View File
@@ -0,0 +1,271 @@
.r6-settings-dialog-layer.embedding-switch-dialog-layer {
z-index: 120;
}
.r6-settings-dialog.embedding-switch-dialog {
box-sizing: border-box;
width: min(600px, calc(100vw - 40px));
max-height: calc(100vh - 48px);
overflow: auto;
color: var(--r6-text);
}
.embedding-switch-dialog > header {
display: flex;
align-items: flex-start;
}
.r6-settings-dialog.embedding-switch-dialog h2 {
margin: 0 0 6px;
font-size: 20px;
line-height: 28px;
}
.embedding-switch-safety p,
.embedding-switch-dialog header p {
margin: 0;
color: var(--r6-muted);
font-size: 13px;
line-height: 22px;
}
.embedding-switch-impact {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin: 22px 0;
border-block: 1px solid var(--bd);
}
.embedding-switch-impact > div {
padding: 14px 16px 14px 0;
}
.embedding-switch-impact > div + div {
padding-left: 16px;
border-left: 1px solid var(--bd);
}
.embedding-switch-impact dt,
.embedding-switch-details dt {
color: var(--meta-reading);
font-size: 12px;
line-height: 18px;
}
.embedding-switch-impact dd,
.embedding-switch-details dd {
margin: 3px 0 0;
color: var(--r6-text);
font-size: 14px;
font-weight: 600;
font-variant-numeric: tabular-nums;
line-height: 21px;
}
.embedding-switch-safety {
display: grid;
grid-template-columns: 170px minmax(0, 1fr);
gap: 10px 18px;
padding: 18px 0;
border-bottom: 1px solid var(--bd);
}
.embedding-switch-safety strong {
font-size: 13px;
line-height: 21px;
}
.embedding-switch-details {
margin: 16px 0 0;
}
.embedding-switch-details summary {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 32px;
color: var(--r6-muted);
font-size: 13px;
cursor: pointer;
}
.embedding-switch-details summary:focus-visible {
outline: 2px solid var(--r6-focus);
outline-offset: 2px;
}
.embedding-switch-alert svg {
flex: 0 0 auto;
margin-top: 2px;
}
.embedding-switch-details[open] summary svg {
transform: rotate(180deg);
}
.embedding-switch-details dl {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px 24px;
margin: 12px 0 0;
padding: 16px;
border-top: 1px solid var(--bd);
border-radius: 0;
background: transparent;
}
.embedding-switch-alert {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 16px;
padding: 11px 12px;
border-radius: 8px;
border: 1px solid var(--dg-bd);
background: var(--dg-bg);
color: var(--dg);
font-size: 13px;
line-height: 20px;
}
.r6-settings-dialog.embedding-switch-dialog footer {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 22px;
}
.embedding-switch-strip,
.embedding-switch-outcome {
box-sizing: border-box;
display: flex;
min-height: 50px;
align-items: center;
gap: 16px;
padding: 9px 18px;
border-bottom: 0.5px solid var(--bd);
background: var(--panel);
color: var(--tx);
}
.embedding-switch-strip-copy,
.embedding-switch-outcome > div:first-child {
display: grid;
min-width: 190px;
gap: 2px;
}
.embedding-switch-strip strong,
.embedding-switch-outcome strong {
font-size: 13px;
font-weight: 600;
line-height: 19px;
}
.embedding-switch-strip span,
.embedding-switch-outcome span {
color: var(--tx2);
font-size: 12px;
font-variant-numeric: tabular-nums;
line-height: 18px;
}
.embedding-switch-progress {
height: 5px;
flex: 1;
overflow: hidden;
border-radius: 999px;
background: var(--bd);
}
.embedding-switch-progress > span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--ac);
transition: width var(--motion-state) var(--motion-enter);
}
.embedding-switch-outcome {
justify-content: space-between;
background: var(--ok-bg);
}
.embedding-switch-outcome.is-error {
background: var(--dg-bg);
}
.embedding-switch-outcome-actions {
display: flex;
align-items: center;
gap: 8px;
}
.embedding-switch-cleanup-error {
margin: 0;
padding: 8px 18px;
border-bottom: 0.5px solid var(--dg-bd);
background: var(--dg-bg);
color: var(--dg);
font-size: 12px;
line-height: 18px;
}
.embedding-switch-outcome-actions > .r6-button:last-child {
width: 36px;
padding-inline: 0;
}
@media (max-width: 700px) {
.embedding-switch-dialog-layer {
align-items: end;
padding: 0;
}
.embedding-switch-dialog {
width: 100%;
max-height: 92vh;
padding: 22px 18px;
border-radius: 14px 14px 0 0;
}
.embedding-switch-impact {
grid-template-columns: 1fr;
}
.embedding-switch-impact > div {
padding: 12px 0;
}
.embedding-switch-impact > div + div {
padding-left: 0;
border-top: 1px solid var(--bd);
border-left: 0;
}
.embedding-switch-safety,
.embedding-switch-details dl {
grid-template-columns: 1fr;
}
.embedding-switch-strip,
.embedding-switch-outcome {
align-items: flex-start;
flex-wrap: wrap;
}
.embedding-switch-progress {
order: 3;
flex-basis: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.embedding-switch-progress > span {
transition: none;
}
}
[data-native-reduce-motion="true"] .embedding-switch-progress > span {
transition: none;
}
@@ -165,6 +165,25 @@ export function modelBlock(
);
}
/** Why Core's circuit breaker paused model work, from `llm_backoff_error`.
*
* Said as what is wrong with the model, never as the code: the reader's next
* step is the model settings, and the code alone does not say which part of
* them to check. Unknown codes read as a plain connection failure. */
export function llmBackoffReasonLabel(code: string): string {
return LLM_BACKOFF_REASON_LABELS[code] ?? "连接模型服务失败";
}
const LLM_BACKOFF_REASON_LABELS: Record<string, string> = {
model_auth_failed: "API Key 无效或没有权限",
model_not_found: "找不到模型或地址",
model_rate_limited: "请求过于频繁,被服务商限流",
model_timeout: "模型响应超时",
model_server_error: "模型服务出错",
model_unreachable: "无法连接模型服务",
model_transport_failed: "连接模型服务失败",
};
/** What went wrong reading a source, said without the code.
*
* These are `ScanIssueCode` plus the one access-level refusal, and they land
+119
View File
@@ -561,6 +561,119 @@
line-height: 20px;
margin: 0;
}
.onboarding-r6-sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.onboarding-r6-mode {
width: min(720px, 100%);
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 20px;
}
.onboarding-r6-mode-intro {
text-align: center;
}
.onboarding-r6-mode-intro h2 {
margin: 0;
color: #263947;
font-size: 20px;
line-height: 30px;
}
.onboarding-r6-mode-intro p {
margin: 8px 0 0;
color: #637381;
font-size: 14px;
line-height: 22px;
}
.onboarding-r6-mode-options {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.onboarding-r6-mode-card {
min-height: 136px;
padding: 20px;
border: 1px solid #c9d5de;
border-radius: 10px;
background: rgb(255 255 255 / 72%);
cursor: pointer;
display: flex;
align-items: flex-start;
gap: 14px;
transition:
border-color var(--r6-fast, 140ms),
background var(--r6-fast, 140ms),
box-shadow var(--r6-fast, 140ms);
}
.onboarding-r6-mode-card:hover {
border-color: #8299aa;
background: rgb(255 255 255 / 76%);
}
.onboarding-r6-mode-card[data-selected="true"] {
border-color: #557c93;
background: #edf3f6;
box-shadow: 0 0 0 2px rgb(85 124 147 / 10%);
}
.onboarding-r6-mode-card:has(input:focus-visible) {
outline: 2px solid #557c93;
outline-offset: 3px;
}
.onboarding-r6-mode-card input {
flex: 0 0 auto;
width: 18px;
height: 18px;
margin: 4px 0 0;
accent-color: #557c93;
}
.onboarding-r6-mode-copy {
min-width: 0;
color: #5f6e79;
font-size: 14px;
line-height: 23px;
display: flex;
flex-direction: column;
gap: 10px;
}
.onboarding-r6-mode-heading {
display: flex;
align-items: center;
gap: 9px;
}
.onboarding-r6-mode-heading strong {
color: #263947;
font-size: 17px;
line-height: 26px;
}
.onboarding-r6-mode-heading > span {
padding: 2px 8px;
border-radius: 999px;
color: #41677e;
background: #dceaf2;
font-size: 11px;
line-height: 18px;
font-weight: 600;
}
.onboarding-r6-mode-error {
min-height: 20px;
margin: -6px 0 0;
color: #944242;
text-align: center;
font-size: 13px;
line-height: 20px;
}
.onboarding-r6-empty {
margin: 0;
padding: 28px 18px;
@@ -698,6 +811,12 @@
.onboarding-r6-body {
padding: 24px 20px;
}
.onboarding-r6-mode-options {
grid-template-columns: 1fr;
}
.onboarding-r6-mode-card {
min-height: 0;
}
.onboarding-r6-discovery {
height: auto;
display: flex;
+2
View File
@@ -28,7 +28,9 @@ export type PollKey =
| "applications"
| "calls"
| "model-profiles"
| "embedding-switch"
| "memory-add-processing"
| "sdk-raw-history"
| "health";
export interface PollTask {
+47
View File
@@ -11,6 +11,41 @@
--r6-fast: 140ms;
}
.r6-settings-dialog-layer {
position: fixed;
inset: 0;
z-index: 100;
display: grid;
place-items: center;
background: rgb(46 66 82 / 18%);
backdrop-filter: blur(8px);
}
.r6-settings-dialog {
width: min(420px, calc(100vw - 40px));
padding: 28px;
border: 1px solid white;
border-radius: 16px;
background: #f3f7f9;
box-shadow: 0 24px 64px rgb(38 59 74 / 18%);
}
.r6-settings-dialog h2 {
margin: 0 0 10px;
font-size: 20px;
}
.r6-settings-dialog p {
margin: 0 0 24px;
color: #63707b;
}
.r6-settings-dialog footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
button.r6-button {
position: relative;
display: inline-flex;
@@ -318,6 +353,18 @@ button.r6-switch:focus-visible,
[data-native-reduce-motion="true"] .r6-button-spinner {
animation: none;
}
@media (prefers-reduced-transparency: reduce) {
.r6-settings-dialog-layer {
background: rgb(46 66 82 / 28%);
backdrop-filter: none;
}
}
[data-native-reduce-transparency="true"] .r6-settings-dialog-layer {
background: rgb(46 66 82 / 28%);
backdrop-filter: none;
}
[data-native-reduce-motion="true"]
:is(.r6-button, .r6-switch-track, .r6-switch-thumb) {
transition: none;
+10
View File
@@ -22,6 +22,16 @@
font-size: 13px;
color: #637786;
}
.r6-memory .memory-mode-notice {
margin: -8px 0 18px;
padding: 12px 14px;
border: 1px solid #d5dfe8;
border-radius: 8px;
background: #f1f5f8;
color: #405665;
font-size: 13px;
line-height: 22px;
}
.r6-memory .memory-pending-add {
display: flex;
align-items: center;
+221 -53
View File
@@ -24,7 +24,7 @@
padding: 20px 24px;
border: 1px solid #d5dfe8;
border-radius: 12px;
background: rgb(255 255 255 / 78%);
background: #fff;
}
.r6-language-setting-title {
margin: 0 0 14px;
@@ -194,6 +194,105 @@
margin: 0;
text-wrap: balance;
}
.r6-memory-mode-settings {
margin: 0 0 32px;
padding: 0 0 32px;
border-bottom: 1px solid #d8e2ea;
}
.r6-mode-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
margin-bottom: 18px;
}
.r6-settings .r6-memory-mode-settings .r6-mode-heading h2 {
margin: 0;
font-size: 20px;
line-height: 32px;
}
.r6-mode-heading p {
margin: 4px 0 0;
color: #5e6e7a;
font-size: 13px;
line-height: 22px;
}
.r6-settings .r6-mode-setting-row {
max-width: none;
margin-top: 4px;
}
.r6-settings .r6-mode-option-menu {
width: 100%;
max-width: 760px;
}
.r6-settings
.r6-mode-option-menu.r6-language-option-menu
.om-option-menu-button {
height: auto;
min-height: 68px;
padding: 10px 44px 10px 14px;
white-space: normal;
}
.r6-settings
.r6-mode-option-menu.r6-language-option-menu
.om-option-menu-button::after {
top: 27px;
}
.r6-mode-option-menu .om-option-menu-copy,
.r6-mode-option-menu-panel .om-option-menu-copy {
display: grid;
gap: 2px;
min-width: 0;
}
.r6-mode-option-menu .om-option-menu-copy > span,
.r6-mode-option-menu-panel .om-option-menu-copy > span {
color: #304757;
font-size: 13px;
font-weight: 500;
line-height: 22px;
}
.r6-mode-option-menu .om-option-menu-copy > small,
.r6-mode-option-menu-panel .om-option-menu-copy > small {
color: #5e6e7a;
font-size: 12px;
font-weight: 400;
line-height: 19px;
overflow-wrap: anywhere;
}
.r6-language-option-menu-panel--settings.r6-mode-option-menu-panel
[role="option"] {
min-height: 62px;
padding: 9px 10px;
white-space: normal;
}
.r6-settings-note {
margin: 14px 0 0;
padding: 10px 12px;
border-radius: 8px;
background: #f1f5f8;
color: #405665;
font-size: 13px;
line-height: 22px;
}
.r6-evolve-settings {
margin-top: 20px;
border-top: 1px solid #d8e2ea;
}
.r6-evolve-settings input[type="time"] {
min-height: 40px;
padding: 0 10px;
border: 1px solid #c6d2da;
border-radius: 8px;
color: #304757;
background: #fff;
}
.r6-settings-dialog ul {
margin: 14px 0 20px;
padding-left: 20px;
color: #4f6373;
font-size: 13px;
line-height: 22px;
}
.r6-settings .r6-setting-row {
box-sizing: border-box;
display: flex;
@@ -374,28 +473,75 @@
font-size: 13px;
line-height: 24px;
}
.r6-settings .r6-model-overview {
padding: 26px 0 20px;
border-bottom: 0;
.r6-settings .r6-model-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.r6-settings .r6-model-caption {
.r6-settings .r6-model-title {
margin: 0;
font-size: 20px;
line-height: 32px;
}
.r6-settings .r6-model-heading p {
margin: 2px 0 0;
color: #63707b;
font-size: 13px;
line-height: 28px;
line-height: 22px;
}
.r6-settings .r6-model-overview {
margin-top: 18px;
border: 1px solid #d8e2ea;
border-radius: 10px;
background: #fff;
}
.r6-settings .r6-model-lane {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 24px;
min-height: 108px;
padding: 18px 20px;
}
.r6-settings .r6-model-lane + .r6-model-lane {
border-top: 1px solid #d8e2ea;
}
.r6-settings .r6-model-lane-copy {
min-width: 0;
}
.r6-settings .r6-model-lane h3 {
margin: 0;
color: #304757;
font-size: 16px;
font-weight: 600;
line-height: 25px;
}
.r6-settings .r6-model-lane p {
margin: 2px 0 9px;
color: #63707b;
margin: 0 0 6px;
font-size: 13px;
line-height: 21px;
}
.r6-settings .r6-current-model {
font:
500 22px / 34px "Noto Sans SC",
"PingFang SC",
sans-serif;
margin: 0 0 7px;
.r6-settings .r6-model-lane strong,
.r6-settings .r6-model-lane small {
display: block;
overflow-wrap: anywhere;
}
.r6-settings .r6-model-state {
.r6-settings .r6-model-lane strong {
color: #304757;
font-size: 14px;
line-height: 28px;
color: #63707b;
margin: 0 0 13px;
font-weight: 500;
line-height: 23px;
}
.r6-settings .r6-model-lane small {
margin-top: 1px;
color: #71808b;
font-size: 12px;
line-height: 20px;
}
.r6-settings .r6-model-lane > .r6-button {
min-width: 72px;
}
.r6-settings .r6-model-recipient {
font-size: 14px;
@@ -436,36 +582,6 @@
background: rgb(158 178 194 / 22%);
color: #0f6e56;
}
.r6-settings-dialog-layer {
position: fixed;
inset: 0;
z-index: 100;
display: grid;
place-items: center;
background: rgb(46 66 82 / 18%);
backdrop-filter: blur(8px);
}
.r6-settings-dialog {
width: min(420px, calc(100vw - 40px));
padding: 28px;
border: 1px solid white;
border-radius: 16px;
background: #f3f7f9;
box-shadow: 0 24px 64px rgb(38 59 74 / 18%);
}
.r6-settings-dialog h2 {
margin: 0 0 10px;
font-size: 20px;
}
.r6-settings-dialog p {
margin: 0 0 24px;
color: #63707b;
}
.r6-settings-dialog footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.r6-settings .r6-model-maintenance {
margin-top: 24px;
}
@@ -614,17 +730,61 @@
box-sizing: border-box;
width: 100%;
min-width: 0;
min-height: 54px;
border: 1px solid #c0ccd5;
min-height: 44px;
border: 1px solid var(--r6-control-edge);
border-radius: 8px;
background: #f0f5f6;
color: #262a2e;
font-size: 15px;
padding: 14px 16px;
background: #fff;
color: var(--r6-text);
font-size: 14px;
padding: 10px 13px;
}
.r6-settings .model-form .om-option-menu-button {
height: 44px;
min-height: 44px;
padding: 0 42px 0 13px;
border-color: var(--r6-control-edge);
background: #fff;
color: var(--r6-text);
font-size: 14px;
}
.r6-settings .model-form .om-option-menu-button::after {
top: 16px;
right: 16px;
}
.r6-settings .model-form .om-option-menu-button[aria-expanded="true"] {
border-color: #7892a6;
box-shadow: 0 0 0 3px rgb(85 124 147 / 12%);
}
.r6-settings .model-form input:not([type="checkbox"]):hover,
.r6-settings .model-form select:hover,
.r6-settings .model-form .om-option-menu-button:hover:not(:disabled) {
border-color: #aabcc8;
background: #fff;
}
.r6-settings .model-form input:not([type="checkbox"]):focus-visible,
.r6-settings .model-form select:focus-visible,
.r6-settings .model-form .om-option-menu-button:focus-visible {
outline: 2px solid var(--r6-focus);
outline-offset: 2px;
}
.r6-settings .model-advanced {
grid-column: 1 / -1;
}
.r6-settings .model-search-intro {
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid #d8e2ea;
}
.r6-settings .model-search-intro strong {
font-size: 15px;
line-height: 24px;
}
.r6-settings .model-search-intro p {
margin: 4px 0 0;
color: #63707b;
font-size: 13px;
line-height: 21px;
}
.r6-settings .model-advanced label {
display: flex;
flex-direction: column;
@@ -729,6 +889,14 @@
padding: 24px 20px;
border-radius: 0 0 11px 11px;
}
.r6-settings .r6-model-lane {
grid-template-columns: minmax(0, 1fr);
gap: 12px;
padding: 16px;
}
.r6-settings .r6-model-lane > .r6-button {
justify-self: start;
}
.r6-settings .r6-language-setting-row {
grid-template-columns: minmax(0, 1fr);
gap: 8px;
Loaded 100 of 149 files, more files were not shown because too many files have changed in this diff. Show more