Compare commits

...
Author SHA1 Message Date
alvinfeiandClaude Opus 5.5 4746681efe fix(sdk): coding reconcile model failures fail the write, nothing half-applied
Experience reconcile no longer falls back to ADD-all, and fact reconcile no
longer inserts without superseding, when the model call fails, returns empty
content, or returns a reply that is not a JSON object with an ops list. The
write returns success=false, agent_error_code=RECONCILE_FAILED and the
cause in reconcile_error_code (LLM_UNAVAILABLE / EMPTY_RESPONSE /
JSON_PARSE_FAILED), the same as chat.

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

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

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 16:51:45 +08:00
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
alvinfeiandClaude Opus 5.5 ce9801b449 fix(memhub): resolve a caller's Hermes root through the grant's own helper
The overlap check resolved the caller-supplied root a second time in new
code. Resolve it with the same helper the grant uses (`resolved_source_root`
in access.py); the Hermes check now compares an already-resolved root and
only resolves other users' stored grant roots itself. A root that cannot be
resolved is left to the grant that follows, which refuses it with its own
code; the `profiles` container is still refused by name before that.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 16:32:17 +08:00
alvinfeiandClaude Opus 5.5 576d9bf408 fix(memhub): multi-user responses echo user_id, cross-user delete answers 404, Hermes profiles isolated for any root
Hardening of the multi-user feature from #250, after review.

- Read responses carry `user_id`, the user actually read (`local` when none
  was named): memory-library, memory-library/layers, user-profile,
  home-summary, memories/graph, raw, context/search, v1/recall and
  memories/ask. ask/stream puts it on its first (`recall`) event. The
  contracts types gain it as optional, and the Host Bridge context validator
  accepts it as an optional key, so an older Core still validates.
- DELETE /api/v2/memories/{id}: 204 only when the caller's own memory is
  deleted; another user's memory, a missing id, and an SDK result with
  deleted_count 0 (a delete that lost a race) all answer the same 404
  `memory_not_found`. The App treats it as already gone; `memhub memories
  delete` prints that it was not found, exits 0, and its JSON is committed
  with `already_gone: true`.
- Hermes skips a `profiles` directory at any depth (discovery helper:
  `excluded_directory_names`), so a root above ~/.hermes never reads another
  user's profile. Connect (other-user root, local connect and authorize)
  refuses a root that is the `profiles` container (422
  `hermes_root_is_profiles_container`) and a root equal to, containing or
  inside another user's active Hermes grant, symlinks resolved (409
  `hermes_root_overlaps_other_user`); `local` on ~/.hermes with another user
  on ~/.hermes/profiles/<name> stays allowed. The CLI explains both codes.
- `sources connect --user-id` with an invalid id says the user id need not
  equal the Hermes profile name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 16:26:20 +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
feitianxiang 3511153f9f Merge pull request #274 from Tencent-Hunyuan/fix/deepseek-harness-profiles
fix(memhub): DeepSeek Harness one switch grants every profile; import versioned sessions and v3 tool text
2026-11-02 14:17:44 +08:00
feitianxiang 23facf75d5 Merge pull request #273 from Tencent-Hunyuan/fix/single-runtime-quit
fix(memhub): one runtime; App quit never stops the background service, stops every runtime when it is disabled
2026-11-02 14:17:32 +08:00
alvinfeiandClaude Opus 5.5 384c7476ad fix(memhub): adopt a newer runtime silently and let quit follow the service state
The owner's decision for a runtime with a higher build than the App: startup
neither fails nor shows anything. The App adopts the runtime and never stops,
downgrades or duplicates it at startup. Quit gives it no special pass, so it
follows the same rules as any adopted runtime: preserved when the service is
enabled or its state is unknown, stopped when the service is disabled. The
now-unused "后台版本比 App 新" message is removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 14:07:57 +08:00
alvinfeiandClaude Opus 5.5 71b8f9be24 Merge github/main into fix/deepseek-harness-profiles
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 14:04:40 +08:00
feitianxiang d257188d09 Merge pull request #270 from Tencent-Hunyuan/fix/commit-validation-and-zh-language
fix(memhub): one commit format at the packaged-runtime boundary; zh-Hans UI sets the SDK language
2026-11-02 14:03:21 +08:00
feitianxiang bde0220373 Merge pull request #268 from Tencent-Hunyuan/feat/sdk-intention-dedup-switch
feat(sdk): configurable intention dedup (on by default, unchanged behaviour)
2026-11-02 14:03:04 +08:00
alvinfeiandClaude Opus 5.5 a88d227cc0 fix(memhub): one DeepSeek Harness switch for every detected profile
The app page assumed one source maps to one host. Harness has none without a
profile, so the switch was greyed out with `unsupported_host` and an installed
profile stayed `install-only` with no way to approve it (#230).

The one switch now covers every `deepseek-harness:<profile>` through the
existing install, grant, revoke and uninstall APIs. On installs and grants each
profile that is not approved, off removes and revokes each linked one. A
profile that fails is named and the others keep their result. The page lists
each profile with its state.

New profiles are connected while the switch is on. The desktop re-reads the
profile list on each host refresh, and the remembered apply gets one more
attempt per new profile. The Runtime re-reads the disk for an unknown profile
id: its list was fixed at start and it outlives the App, so one new profile
made the host list fail for every app.

Rewrite the harness doc to match, removing the sources page it described,
which is not mounted. Add the strings to all 11 locales.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 13:11:19 +08:00
alvinfeiandClaude Opus 5.5 059125a09f fix(memhub): one runtime; App quit never stops the background service, stops every runtime when it is disabled
Quit no longer trusts what startup saw. `transientRuntime` and
`preserveManagedRuntimeOnQuit` were derived from the launch path, so an
already-running transient Runtime was treated as a managed service and
outlived the App (#237, #238, #239).

- queryRuntimeServiceState asks launchd/systemd at quit time, every time.
  Enabled: a LaunchAgent declaring this data dir is installed, there is no
  `launchctl disable` override, and the enable marker ("Launch at login")
  is present. A failed or timed-out query is `unknown`.
- Quit: enabled -> never stop. Disabled -> stopAllRuntimeProcesses stops
  every Runtime of the data dir (App child, CLI-started, leftover), each
  pid verified via the receipt and the authenticated identity endpoint; an
  unverifiable lease owner is reported, never signalled. Unknown -> stop
  nothing and log it.
- Startup reconciles a running Runtime by build: the Runtime now publishes
  build_number/git_commit in runtime-service.json. Same build is adopted;
  an older service job is replaced through launchd (installUserService
  kickstart/re-bootstrap), an older transient/CLI one is stopped gracefully
  and restarted; a newer one is never downgraded or stopped; an unknown
  build is adopted and never stopped. A stray beside an enabled service is
  stopped so exactly one Runtime remains.
- The tray and the login preference re-read the real state on each refresh.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 12:57:21 +08:00
alvinfeiandClaude Opus 5.5 0d0cefb25e fix(core): leave no cursor on a DeepSeek Harness session it refuses
An unknown session version was skipped record by record. Each skip carried a
cursor and the file ended with a cursor touch, so the refused session sat
behind a checkpoint: the release that learned the format would read nothing,
turning "not imported yet" into "never imported". The skip was also not
reported, since `report_issue` defaults to false.

Refuse the file from its header instead, with the cursorless
`ScanFailure(UNSUPPORTED_SCHEMA)` other sources already use. The parser check
stays for callers that hand it records directly.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 12:55:17 +08:00
alvinfeiandClaude Opus 5.5 be8cf028ff test(core): pin DeepSeek Harness text tool output and session name boundary
Cover the #175 tool-output half directly: a `tool/result` written as plain
`text` blocks imports next to the older `tool-result` block shape, goes
through the same credential scrubbing and clipping, and names outside
`session[.vN].jsonl[.zstd]` stay out. Correct the contract-suite comments that
still said only `session.jsonl` was accepted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 12:47:17 +08:00
alvinfeiandClaude Opus 5.5 ef5ff06590 fix(sdk): intention dedup switch also gates write-batch dedup
When intention_dedup_enabled is off, no intention dedup should run, but
the writer's batch dedup still dropped near-duplicate intentions from
one extraction. Gate it on the same switch. Its threshold stays on
MEMORY_DEDUP_THRESHOLD.

Also skip the embedding backfill when dedup is off, since only dedup
uses it, and fall back to the env default on an invalid configured
threshold so a bad value cannot fail the read.

Refs #241

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 12:33:56 +08:00
ryanlandClaude Opus 5.5 7eb38aa6ad fix(core): import versioned DeepSeek Harness session files
DSH names a session file after its format generation
(`session.v3.jsonl.zstd`, `session.v4.jsonl.zstd`), but discovery matched only
the two unversioned names, so import jobs completed with `discovered 0` while
the sessions sat on disk.

Discover `session[.vN].jsonl[.zstd]` and gate parsing on the header `version`
field: verified generations (0/3/4) import, anything newer is skipped as
`unsupported_schema` rather than interpreted with today's field rules. v4 tool
results use plain `text` blocks, so read that shape through the same clipping
and credential-scrubbing path as the `tool-result` blocks used by v0/v3.

(cherry picked from commit 2a65e5ab2d8548cfb8d6f72a4b68b702f6b4a3cc; conflicts in CHANGELOG.md and the Core test inventory resolved against main)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 12:32:02 +08:00
alvinfeiandClaude Opus 5.5 2624228f6f feat(sdk): configurable intention dedup (on by default, unchanged behaviour)
Add MemoryConfig.recall.intention_dedup_enabled (default true, env
MEMORY_INTENTION_DEDUP_ENABLED) and recall.intention_dedup_threshold
(env MEMORY_INTENTION_DEDUP_THRESHOLD, falling back to the shared
MEMORY_DEDUP_THRESHOLD, then 0.95). The reader passes both from the
client config to recall_intentions. When off, read-time intention dedup
is skipped entirely: nothing is hidden and nothing is deleted.

Refs #241

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 12:25:37 +08:00
124 changed files with 6945 additions and 813 deletions

No files matched your search

+118
View File
@@ -4,6 +4,124 @@ 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`).
- Hy Memory: 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.
- 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.
- 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.
## 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`,
`MEMORY_INTENTION_DEDUP_THRESHOLD`. When on, behaviour is unchanged
(duplicates found at read time are still deleted); when off, no intention
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
+20
View File
@@ -182,9 +182,29 @@ SDK 不设置 Rerank Search 并发上限。开启后模型在 `HyMemoryClient`
| `MEMORY_RECALL_STRENGTH_TAU` | `180` | 衰减时间常数,天 |
| `MEMORY_RECALL_ACCESS_TRACKING` | `true` | Strength 开启时是否回写访问信息 |
| `MEMORY_ENTITY_STORE_ENABLED` | `false` | 是否维护独立 entity collection;当前 Reader 不直接读取 |
| `MEMORY_INTENTION_DEDUP_ENABLED` | `true` | 是否对 L7 Intention 判重(读侧和写入侧批内);见下文 |
| `MEMORY_INTENTION_DEDUP_THRESHOLD` | `MEMORY_DEDUP_THRESHOLD`,再缺省为 `0.95` | Intention 读侧判重的 cosine 阈值 |
旧数据可调用当前公开方法 `client.build_entity_store(user_id)` 补建 Entity Store。
Intention 判重:开启时(默认,与以往行为一致),Search 召回的 L7 Intention 中,
同 `user_id` + 同 `agent_id` + `memory_at` 同一天 + `valid_until` 相同、且 cosine ≥ 阈值的
条目视为复本,只返回最早写入的一条,其余在后台从向量库**删除**。判重不区分内容中的项目、
对象等语义差异,内容高度相似但对象不同的意图可能被误删;写入时同一批抽取结果也会做一次
批内判重。关闭后这两处都不判重,不隐藏、不丢弃、也不删除意图。两项对应
`MemoryConfig.recall.intention_dedup_enabled` /
`recall.intention_dedup_threshold`,按 client 生效,环境变量只提供默认值:
```python
client = HyMemoryClient.from_config({
"recall": {"intention_dedup_enabled": False},
})
```
`MEMORY_DEDUP_THRESHOLD` 同时被写入侧批内意图判重和 dedup 内核使用;
`MEMORY_INTENTION_DEDUP_THRESHOLD` 只影响读侧 Intention 判重,写入侧批内判重的阈值
仍取 `MEMORY_DEDUP_THRESHOLD`。
## 日志、Trace 与历史
| 变量 | 默认值 | 说明 |
+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,
+19
View File
@@ -432,6 +432,14 @@ class RecallConfig:
retrieval_clauses_require_keyword: bool = None # env: MEMORY_RETRIEVAL_CLAUSES_REQUIRE_KEYWORD
retrieval_clauses_min_keyword_score: float = None # normalized BM25 floor
supplemental_propositions_enabled: bool = None # env: MEMORY_SUPPLEMENTAL_PROPOSITIONS_ENABLED
# ── Intention(L7)判重 ──
# 开启(默认):召回时同 user+agent+同天 memory_at+同 valid_until 且
# cosine≥阈值 的意图视为复本,只返回最早一条,其余异步从 VDB 删除(历史行为)。
# 关闭:读侧与写入侧批内都不判重——不隐藏、不丢弃、不删除任何意图。
intention_dedup_enabled: bool = None # env: MEMORY_INTENTION_DEDUP_ENABLED
# 读侧意图判重 cosine 阈值。env: MEMORY_INTENTION_DEDUP_THRESHOLD,
# 未设置时回退到共享的 MEMORY_DEDUP_THRESHOLD(兼容旧部署),默认 0.95。
intention_dedup_threshold: float = None
# 最小分数阈值
min_score_threshold: float = 0.0
@@ -750,6 +758,15 @@ class RecallConfig:
self.supplemental_propositions_enabled = _get_env_bool(
"MEMORY_SUPPLEMENTAL_PROPOSITIONS_ENABLED", False
)
if self.intention_dedup_enabled is None:
self.intention_dedup_enabled = _get_env_bool(
"MEMORY_INTENTION_DEDUP_ENABLED", True
)
if self.intention_dedup_threshold is None:
self.intention_dedup_threshold = _get_env_float(
"MEMORY_INTENTION_DEDUP_THRESHOLD",
_get_env_float("MEMORY_DEDUP_THRESHOLD", 0.95),
)
@dataclass
@@ -1384,6 +1401,8 @@ class MemoryConfig:
"supplemental_propositions_enabled": (
self.recall.supplemental_propositions_enabled
),
"intention_dedup_enabled": self.recall.intention_dedup_enabled,
"intention_dedup_threshold": self.recall.intention_dedup_threshold,
},
"pipeline": {
"default_version": self.pipeline.default_version,
+31 -10
View File
@@ -15,6 +15,9 @@ HY Memory - Intention (L7) 召回 + 惰性过期转换 + 复本判定
→ 视为同一个意图的复本(extractor 复读机 / 多 session 重复表达)。
保留组里 `gmt_created` 最早的(老的先来先得,稳定 id);
- **异步删库**:其余复本 fire-and-forget 从 VDB 删除,不影响本次调用 RT。
- **开关**:判重受 `MemoryConfig.recall.intention_dedup_enabled`(默认开)与
`recall.intention_dedup_threshold` 控制,由 reader 按 client 配置传入。
关闭时完全跳过判重:不隐藏、不删除任何意图(写入侧批内判重同样跳过)。
Q: 为什么要看 memory_at 和 valid_until?
A: 意图是 point-in-time 信号。同一句"我周五要开会",如果一条 memory_at 在
@@ -38,8 +41,14 @@ from ...models.memory import MemoryLayer, MemoryStatus
logger = logging.getLogger(__name__)
def _get_dedup_threshold() -> float:
"""去重 cosine 阈值:沿用 dedup 内核的 MEMORY_DEDUP_THRESHOLD,默认 0.95。"""
def _get_dedup_threshold(configured: Optional[float] = None) -> float:
"""去重 cosine 阈值:优先用 client 配置;否则(或配置非法)沿用 dedup 内核的
MEMORY_DEDUP_THRESHOLD,默认 0.95。"""
try:
if configured is not None:
return float(configured)
except (TypeError, ValueError):
logger.warning(f"[intention] invalid dedup threshold: {configured!r}")
try:
return float(os.environ.get("MEMORY_DEDUP_THRESHOLD", 0.95))
except (TypeError, ValueError):
@@ -244,6 +253,8 @@ async def recall_intentions(
stats_out: Optional[Dict[str, Any]] = None,
include_sources: Optional[List[str]] = None,
exclude_sources: Optional[List[str]] = None,
dedup_enabled: bool = True,
dedup_threshold: Optional[float] = None,
) -> List[Dict[str, Any]]:
"""
召回未过期的 L7_INTENTION 节点(VDB),惰性过期转 L2_FACT,判重并异步删复本。
@@ -260,6 +271,11 @@ async def recall_intentions(
now: 当前时间(默认 datetime.now(),测试可注入)
stats_out: 可选 dict,本函数把分段耗时与数量**原地回填**进去,供调用方落
READ_INTENTION pipeline log。传 None 则完全不统计(零开销)。
dedup_enabled: 是否执行读侧判重(及复本异步删库)。False 时跳过判重,
所有未过期意图原样返回,不删除任何节点。默认 True(历史行为)。
dedup_threshold: 判重 cosine 阈值。None 时回退到 env
MEMORY_DEDUP_THRESHOLD(默认 0.95),兼容直接调用本函数的旧代码;
reader 总是传入 `recall.intention_dedup_threshold`。
Returns:
[{"node_id", "score", "node", "layer": "l7_intention", "source": "vdb_intention"}, ...]
@@ -272,7 +288,7 @@ async def recall_intentions(
埋点字段(回填进 stats_out):
耗时(ms): vdb_search_ms / expire_ms / backfill_embed_ms / dedup_ms / total_ms
数量: fetched / expired / survivors / dedup_dropped / returned
配置: fetch_limit / out_limit / dedup_threshold
配置: fetch_limit / out_limit / dedup_enabled / dedup_threshold
说明: expire_ms、backfill_embed_ms 只有真的发生了对应操作才非 0,
借此可判断慢在 VDB 检索、逐条过期转换、补拉向量还是判重计算。
"""
@@ -358,7 +374,7 @@ async def recall_intentions(
_t = time.perf_counter()
_backfilled = 0
_missing_cnt = 0
if survivors:
if survivors and dedup_enabled: # 补拉只为判重;关闭时无需这次 VDB 读
missing_ids = [
h.get("node_id") for h in survivors
if h.get("node_id") and not getattr(h.get("node"), "embedding", None)
@@ -391,18 +407,23 @@ async def recall_intentions(
)
# 2. 判重:同 user + 同天 memory_at + valid_until 同日 + cosine≥threshold
# dedup_enabled=False → 完全跳过:不隐藏、不删除。
_t = time.perf_counter()
threshold = _get_dedup_threshold()
try:
kept, delete_ids = _partition_duplicates(survivors, threshold)
except Exception as e:
# 任何 dedup 异常 → 降级为不去重(返回全部 survivors 截断 limit)
logger.warning(f"[intention] dedup partition failed, degrade to raw: {e}")
threshold = _get_dedup_threshold(dedup_threshold)
if dedup_enabled:
try:
kept, delete_ids = _partition_duplicates(survivors, threshold)
except Exception as e:
# 任何 dedup 异常 → 降级为不去重(返回全部 survivors 截断 limit)
logger.warning(f"[intention] dedup partition failed, degrade to raw: {e}")
kept, delete_ids = survivors, []
else:
kept, delete_ids = survivors, []
# 纯 CPU(cosine 两两比较);survivors 多时可能显著
_stat(
dedup_ms=round((time.perf_counter() - _t) * 1000, 2),
dedup_dropped=len(delete_ids),
dedup_enabled=bool(dedup_enabled),
dedup_threshold=threshold,
)
+6
View File
@@ -1856,6 +1856,12 @@ class BaseReadPipeline(ReadPipeline):
stats_out=_intent_stats,
include_sources=request.include_sources,
exclude_sources=request.exclude_sources,
dedup_enabled=bool(getattr(
self.config.recall, "intention_dedup_enabled", True
)),
dedup_threshold=getattr(
self.config.recall, "intention_dedup_threshold", None
),
)
await trace_log.log_intention(
stats=_intent_stats,
+76 -31
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)
@@ -635,6 +648,7 @@ class MemoryWriter(WritePipeline):
不查 VDB、不做"新写入 vs 库里已有"的对比,避免给 VDB 增加读压力。
库内存量污染由 recall 侧异步删除逐步清理。best-effort:任何异常都
回退到"不判重、全写入",绝不影响 upsert 主流程。
`recall.intention_dedup_enabled=False` 时跳过批内判重,全部写入。
"""
if not intentions:
return []
@@ -660,7 +674,11 @@ class MemoryWriter(WritePipeline):
# ============ 批内 dedup(不查 VDB,只对本批 intentions 互斥判重) ============
keep_mask: List[bool] = [True] * len(intentions)
try:
dropped = self._intention_batch_dedup(intentions, embeddings)
dropped = (
self._intention_batch_dedup(intentions, embeddings)
if getattr(self.config.recall, "intention_dedup_enabled", True)
else []
)
for i in dropped:
if 0 <= i < len(keep_mask):
keep_mask[i] = False
@@ -1257,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]] = []
@@ -1292,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 = {}
@@ -1303,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 输出,
# 这里不再逐条打印(避免重复刷屏)。
@@ -2502,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
@@ -3526,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
@@ -3654,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:
+9
View File
@@ -137,6 +137,15 @@ memhub profile
`user-profile` / `memhub profile` 调用 SDK `get_user_profile()`,返回组装后的
用户长期画像,不通过普通 Search 拼装。
读接口都回显 `user_id`(实际读的用户,未指定时为 `local`):
`GET /api/v2/memory-library`、`/memory-library/layers`、`/user-profile`、
`/user-profile/versions`、`/home-summary`、`/memories/graph`、`/raw`,
`POST /api/v2/context/search`、`/api/v1/recall`、`/api/v2/memories/ask`;
`/memories/ask/stream` 写在第一个 `recall` 事件上。
`DELETE /api/v2/memories/{id}` 只在删掉调用者自己的记忆时回 204;另一个用户的
记忆和不存在的 ID 一律回 404 `memory_not_found`(不暴露是否存在),调用方按
「已不存在」处理。
返回值:
```python
+2 -2
View File
@@ -18,7 +18,7 @@ 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`
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.12`。MemHub 源码版本为 `1.0.6`
(build 37),GitHub 仓库为
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
`memhub-cli`。当前代码架构见
@@ -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.12`。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.12",
"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.12
License: Apache-2.0
+25 -13
View File
@@ -1938,7 +1938,7 @@ async function runCommand(
return reject(
descriptor,
asJson,
"--user-id 和 --root 只能用于接入单个 hermes 来源(Hermes profile),用户 ID 只能是 1–64 位字母、数字、点、下划线或连字符。",
"--user-id 和 --root 只能用于接入单个 hermes 来源(Hermes profile),用户 ID 只能是 1–64 位字母、数字、点、下划线或连字符。用户 ID 不必等于 Hermes profile 名,例如 --user-id wife-work --root ~/.hermes/profiles/wife:work。",
);
}
if (!parsed) return usage();
@@ -2328,29 +2328,41 @@ async function runCommand(
...userIdParameters(userId),
}),
);
// A delete answered 404 `memory_not_found` is already gone (deleted
// before, or never this user's): what was asked for holds, so it exits 0
// as committed and says so, instead of failing a retry or a double run.
const alreadyGone =
parsed.kind === "delete" &&
outcome.state === "rejected" &&
outcome.code === "memory_not_found";
const state = alreadyGone ? "committed" : outcome.state;
const document = {
schema_version: 2,
product: PRODUCT,
command: `memories ${parsed.kind}`,
state: outcome.state,
state,
operation: parsed.kind,
memory_id: parsed.memoryId,
...(outcome.state === "committed"
? parsed.kind === "update"
? { result: outcome.data }
: {}
: { code: outcome.code }),
...(alreadyGone
? { already_gone: true }
: outcome.state === "committed"
? parsed.kind === "update"
? { result: outcome.data }
: {}
: { code: outcome.code }),
};
emit(
asJson
? `${JSON.stringify(document)}\n`
: outcome.state === "committed"
? `已${parsed.kind === "update" ? "更新" : "删除"}记忆 ${parsed.memoryId}\n`
: outcome.code === "memory_id_ambiguous"
? "这个 ID 同时存在于多个场景;请加 --kind(以及 --variant)指明是哪一条。\n"
: `操作失败:${outcome.code}\n`,
: alreadyGone
? `未找到记忆 ${parsed.memoryId}(可能已删除),无需再删。\n`
: outcome.state === "committed"
? `已${parsed.kind === "update" ? "更新" : "删除"}记忆 ${parsed.memoryId}\n`
: outcome.code === "memory_id_ambiguous"
? "这个 ID 同时存在于多个场景;请加 --kind(以及 --variant)指明是哪一条。\n"
: `操作失败:${outcome.code}\n`,
);
return outcome.state === "committed" ? EXIT_OK : EXIT_FAILED;
return state === "committed" ? EXIT_OK : EXIT_FAILED;
}
if (
named === "settings" &&
+6 -5
View File
@@ -258,7 +258,7 @@ export const COMMANDS: readonly CommandDescriptor[] = [
{
name: "sources",
summary:
"列出来源,直接接入来源,或按 request id 查接入回执。connect 不加 --import 只授权(处理保持冻结:不导入历史、不扫描新会话);加 --import 同时导入历史并开启持续同步,返回 request id。目录来自环境变量覆盖的来源必须加 --confirm-overrides。Hermes 的每个 profile 是一个独立用户:用 --user-id 和 --root 接入,授权并导入历史(会自动排除 ~/.hermes/profiles/**,各用户互不读取)。",
"列出来源,直接接入来源,或按 request id 查接入回执。connect 不加 --import 只授权(处理保持冻结:不导入历史、不扫描新会话);加 --import 同时导入历史并开启持续同步,返回 request id。目录来自环境变量覆盖的来源必须加 --confirm-overrides。Hermes 的每个 profile 是一个独立用户:用 --user-id 和 --root 接入,授权并导入历史(任何层级的 profiles/ 目录都会被排除,各用户互不读取)。--root 不能是 profiles 目录本身,也不能与另一个用户的 Hermes 目录相同或互相包含(~/.hermes 与 ~/.hermes/profiles/<name> 除外)。",
usage:
"memhub sources list [--json]\n memhub sources connect <id> [<id> ...] [--import] [--confirm-overrides] [--request-id <uuid>] [--json]\n memhub sources connect hermes --user-id <id> --root <path> [--import] [--request-id <uuid>] [--json]\n memhub sources connect --all-detected [--import] [--confirm-overrides] [--json]\n memhub sources receipt <request-id> [--json]",
options: [
@@ -270,7 +270,7 @@ export const COMMANDS: readonly CommandDescriptor[] = [
{
flag: "--user-id <id>",
summary:
"只用于 hermes:这条接入属于哪个用户(默认 local)。和 --root 一起接入一个 Hermes profile,例如 --user-id wife --root ~/.hermes/profiles/wife;其他来源只能是 local。",
"只用于 hermes:这条接入属于哪个用户(默认 local)。和 --root 一起接入一个 Hermes profile,例如 --user-id wife --root ~/.hermes/profiles/wife;用户 ID 不必等于 profile 名,例如 --user-id wife-work --root ~/.hermes/profiles/wife:work。其他来源只能是 local。",
},
{
flag: "--root <path>",
@@ -397,7 +397,7 @@ export const COMMANDS: readonly CommandDescriptor[] = [
{
name: "memories",
summary:
"列出、搜索、查看、添加、修改或物理删除记忆(对话、工作、编码三个场景)。add 和 App 的「添加记忆」是同一个入口;delete 不可逆,需 --yes。",
"列出、搜索、查看、添加、修改或物理删除记忆(对话、工作、编码三个场景)。add 和 App 的「添加记忆」是同一个入口;delete 不可逆,需 --yes;要删的记忆已不存在(或不属于该用户)时提示未找到,按已删除处理并退出 0。",
usage:
"memhub memories list [--scene <all|chat|working|coding>] [--source <id>] [--query <text>] [--status <state>] [--offset <n>] [--limit <n>] [--user-id <id>] [--json]\n memhub memories show <memory-id> [--kind <kind>] [--variant <variant>] [--user-id <id>] [--json]\n memhub memories add <text> [--request-id <uuid>] [--user-id <id>] [--json]\n memhub memories add --content-file <path> [--request-id <uuid>] [--user-id <id>] [--json]\n memhub memories add-status <request-id> [--user-id <id>] [--json]\n memhub memories update <memory-id> --content-file <path> [--kind <kind>] [--variant <variant>] [--task <text>] [--user-id <id>] [--json]\n memhub memories delete <memory-id> [--kind <kind>] [--variant <variant>] [--yes] [--user-id <id>] [--json]",
options: [
@@ -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> ...]",
+13
View File
@@ -348,12 +348,25 @@ export function parseApiKey(bytes: Buffer | null): string | undefined {
return value;
}
/** What to do about a refusal whose code alone does not say. */
const FLOW_CODE_HINTS: Record<string, string> = {
hermes_root_is_profiles_container:
"--root 指向的是 profiles 目录本身;请指向其中一个 profile,例如 ~/.hermes/profiles/wife。",
hermes_root_overlaps_other_user:
"--root 与另一个用户已接入的 Hermes 目录相同或互相包含,会读到对方的记录;请换一个目录,或先撤销对方的接入。~/.hermes 与 ~/.hermes/profiles/<name> 可以分属两个用户。",
};
export function renderFlowText(document: FlowDocument): string {
const lines = [
`操作状态:${document.state}`,
`操作 ID:${document.operation_id ?? "无"}`,
"code" in document ? `代码:${document.code}` : "已返回有界回执。",
];
const hint =
"code" in document && typeof document.code === "string"
? FLOW_CODE_HINTS[document.code]
: undefined;
if (hint) lines.push(hint);
// A partial success or an all-rejected batch must name each source that was
// not accepted; a bare "partial" leaves the user with nothing to act on.
if (
+27
View File
@@ -366,6 +366,33 @@ describe("memories update / delete pass the namespace", () => {
);
});
it("reports a delete answered 404 as already gone and exits 0", async () => {
transport.delete.mockResolvedValue({
status: 404,
data: { detail: "memory_not_found" },
});
const json = await capturedRun([
"memories",
"delete",
"gone-1",
"--yes",
"--json",
]);
expect(json.code).toBe(0);
expect(JSON.parse(json.stdout)).toEqual({
schema_version: 2,
product: expect.any(String),
command: "memories delete",
state: "committed",
operation: "delete",
memory_id: "gone-1",
already_gone: true,
});
const text = await capturedRun(["memories", "delete", "gone-1", "--yes"]);
expect(text.code).toBe(0);
expect(text.stdout).toContain("未找到记忆 gone-1");
});
it("explains an ambiguous id instead of a bare code", async () => {
transport.delete.mockResolvedValue({
status: 409,
+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}`);
}
+8 -4
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 =
@@ -357,7 +358,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("、")}`,
);
}
+42
View File
@@ -373,6 +373,48 @@ describe("sources connect for a Hermes profile", () => {
}
expect(transport.post).not.toHaveBeenCalled();
});
it("says the user id need not equal the Hermes profile name", async () => {
const result = await capturedRun([
"sources",
"connect",
"hermes",
"--user-id",
"wife:work",
"--root",
"~/.hermes/profiles/wife:work",
]);
expect(result.code).toBe(2);
expect(result.stderr).toContain("用户 ID 不必等于 Hermes profile 名");
expect(result.stderr).toContain(
"--user-id wife-work --root ~/.hermes/profiles/wife:work",
);
expect(transport.post).not.toHaveBeenCalled();
});
it("explains a refused overlapping or profiles-container root", async () => {
for (const code of [
"hermes_root_is_profiles_container",
"hermes_root_overlaps_other_user",
]) {
transport.post.mockResolvedValueOnce({
status: code.endsWith("container") ? 422 : 409,
data: { detail: code },
});
const result = await capturedRun([
"sources",
"connect",
"hermes",
"--user-id",
"kid",
"--root",
"/profiles",
]);
expect(result.code).toBe(1);
expect(result.stdout).toContain(code);
expect(result.stdout).toContain("--root");
}
});
});
async function capturedRun(arguments_: string[]) {
+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.12",
# 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",
+172 -24
View File
@@ -130,6 +130,7 @@ from one_memory.hy_sdk_types import (
from one_memory.imports.access import (
AuthorizedSourceRoot,
SourceAccessError,
resolved_source_root,
root_identity_matches,
source_root_identity,
)
@@ -169,6 +170,11 @@ from one_memory.imports.locations import SourceLocation, inspect_source_location
from one_memory.imports.models import EvidenceRecord
from one_memory.imports.runner import live_sync_arrival
from one_memory.imports.sources.common import SourceInstallState
from one_memory.imports.sources.hermes import (
HERMES_ROOT_IS_PROFILES_CONTAINER,
hermes_root_conflict,
hermes_root_is_profiles_container,
)
from one_memory.imports.sources.registry import (
RootCandidateKind,
SourceRegistration,
@@ -198,12 +204,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,
@@ -526,11 +537,25 @@ class SdkMemoryUpdateRequest(BaseModel):
class SdkUserProfileResponse(BaseModel):
model_config = ConfigDict(extra="forbid")
#: The user whose profile this is: the `user_id` asked for, else `local`.
user_id: str
request_id: str
profile: dict[str, object] | None
elapsed_ms: float = Field(ge=0)
class SdkMemoryLibraryPage(HySdkMemoryPage):
"""`GET /api/v2/memory-library`: one SDK page and the user it was read for."""
model_config = ConfigDict(frozen=True, extra="forbid")
user_id: str
@classmethod
def of(cls, page: HySdkMemoryPage, user_id: str) -> "SdkMemoryLibraryPage":
return cls(**dict(page), user_id=user_id)
class SdkProfileVersionsResponse(HySdkProfileVersions):
model_config = ConfigDict(frozen=True, extra="forbid")
@@ -585,6 +610,8 @@ class SdkRawItem(HySdkRawRecord):
class SdkRawPage(BaseModel):
model_config = ConfigDict(extra="forbid")
#: The user whose raw turns these are.
user_id: str
items: tuple[SdkRawItem, ...] = ()
total: int = Field(ge=0)
offset: int = Field(ge=0)
@@ -598,6 +625,8 @@ class SdkRawPage(BaseModel):
class SdkLayerCounts(BaseModel):
model_config = ConfigDict(extra="forbid")
#: The user whose memories were counted.
user_id: str
#: Every SDK layer, zero when empty.
layers: dict[str, int]
total: int = Field(ge=0)
@@ -763,8 +792,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
@@ -824,6 +854,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`."""
@@ -831,15 +885,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(
@@ -967,6 +1022,8 @@ class AskMemoryRequest(SdkSearchFilters):
class SdkHomeSummary(BaseModel):
schema_version: Literal[2] = 2
#: The user the card was read for.
user_id: str
memory_count: int
latest: HySdkMemoryRecord | None
observed_at: datetime
@@ -993,6 +1050,8 @@ class MemoryGraphEdge(BaseModel):
class MemoryGraphResponse(BaseModel):
#: The user whose memories were mapped.
user_id: str
total_memories: int
projected_memories: int
nodes: tuple[MemoryGraphNode, ...]
@@ -2667,6 +2726,7 @@ def create_app(
)
if not root_identity_matches(registration.candidate.path, request.expected_root_id):
raise HTTPException(status_code=409, detail="source_identity_changed")
await _refuse_overlapping_root(source_id, registration.candidate.path, DEFAULT_USER_ID)
root_changed = prior is not None and prior.root_id != request.expected_root_id
if root_changed:
source_jobs = [job for job in _job_store(app).list_jobs() if job.source_id == source_id]
@@ -3067,6 +3127,20 @@ def create_app(
registrations = [_require_registration(app, source_id) for source_id in source_ids]
if request.request_id is None:
raise HTTPException(status_code=409, detail="source_confirmation_required")
for registration in registrations:
if registration.descriptor.source_id in MULTI_USER_SOURCES:
try:
resolved = await asyncio.to_thread(
app.state.source_candidate_store.resolve, registration
)
except SourceCandidateError:
# The connect below reports the missing candidate itself.
continue
await _refuse_overlapping_root(
registration.descriptor.source_id,
resolved.candidate.path,
DEFAULT_USER_ID,
)
history_after = await _onboarding_history_after(
request,
registrations,
@@ -3166,6 +3240,42 @@ def create_app(
raise
return ConnectSourcesResponse(**result.model_dump())
async def _refuse_overlapping_root(source_id: str, root: Path, user_id: str) -> None:
"""Refuse a Hermes root that would read another user's files.
422 `hermes_root_is_profiles_container` for `.../profiles` itself;
409 `hermes_root_overlaps_other_user` for a root equal to, holding or
inside another user's active grant, unless one is the other's
`profiles/<name>` (the scan never enters `profiles/`).
"""
if source_id not in MULTI_USER_SOURCES:
return
if hermes_root_is_profiles_container(root):
raise HTTPException(status_code=422, detail=HERMES_ROOT_IS_PROFILES_CONTAINER)
def conflict_with_others() -> str | None:
try:
mine = resolved_source_root(root)
except SourceAccessError:
# Unreadable or missing: the grant that follows refuses it
# with its own code.
return None
others = [
Path(path)
for receipt, path in _grant_store(app).grants()
if receipt.source_id == source_id
and receipt.user_id != user_id
and receipt.revoked_at is None
]
return hermes_root_conflict(mine, others)
conflict = await asyncio.to_thread(conflict_with_others)
if conflict is not None:
raise HTTPException(
status_code=422 if conflict == HERMES_ROOT_IS_PROFILES_CONTAINER else 409,
detail=conflict,
)
async def _connect_other_root(request: ConnectSourcesRequest) -> ConnectSourcesResponse:
"""Grant one Hermes root to a user: `~/.hermes/profiles/<name>` for its own user.
@@ -3195,6 +3305,7 @@ def create_app(
root = Path(request.root)
if not root.is_absolute():
raise HTTPException(status_code=422, detail="source_root_must_be_absolute")
await _refuse_overlapping_root(source_id, root, request.user_id)
# Named outright by the caller, like an environment override:
# no saved candidate may replace it, and naming it confirms it.
registration = replace(
@@ -3730,6 +3841,7 @@ def create_app(
raise _sdk_http_error(error, "hy_memory_list_failed") from None
total = stats.get("total") if isinstance(stats, dict) else None
return SdkHomeSummary(
user_id=user_id,
memory_count=total if isinstance(total, int) and total >= 0 else page.total,
latest=page.memories[0] if page.memories else None,
observed_at=datetime.now(UTC),
@@ -3737,7 +3849,7 @@ def create_app(
@app.get(
"/api/v2/memory-library",
response_model=HySdkMemoryPage,
response_model=SdkMemoryLibraryPage,
dependencies=strict,
)
async def sdk_memory_library(
@@ -3754,7 +3866,7 @@ def create_app(
exclude_sources: list[str] | None = Query(default=None), # noqa: B008
session_ids: list[str] | None = Query(default=None), # noqa: B008
agent_ids: list[str] | None = Query(default=None), # noqa: B008
) -> HySdkMemoryPage:
) -> SdkMemoryLibraryPage:
"""One page of memories across chat, working and coding.
Without `query`: `list_memories(scene="all")`, newest update first;
@@ -3800,7 +3912,7 @@ def create_app(
status_code=422,
detail="sdk_search_supports_active_status_only",
)
return await _library_search_page(
searched = await _library_search_page(
app,
client,
query=search_query,
@@ -3813,6 +3925,7 @@ def create_app(
user_id=user_id,
filters=filters,
)
return SdkMemoryLibraryPage.of(searched, user_id)
raw = await asyncio.to_thread(
client.list_memories,
user_id=user_id,
@@ -3830,7 +3943,7 @@ def create_app(
raw, limit=limit, offset=offset
)
_log_skipped_sdk_memories(skipped)
return page
return SdkMemoryLibraryPage.of(page, user_id)
except HTTPException:
raise
except Exception as error:
@@ -3876,6 +3989,7 @@ def create_app(
except Exception as error:
raise _sdk_http_error(error, "hy_memory_list_failed") from None
return SdkLayerCounts(
user_id=user_id,
layers=counts,
total=sum(counts.values()),
statuses=tuple(chosen) if chosen else None,
@@ -4036,6 +4150,7 @@ def create_app(
)
)
return MemoryGraphResponse(
user_id=user_id,
total_memories=total if isinstance(total, int) else len(memories),
projected_memories=len(memories),
nodes=tuple(nodes.values()),
@@ -4057,7 +4172,7 @@ def create_app(
profile = HySdkUserProfile.from_sdk(value)
except Exception as error:
raise _sdk_http_error(error, "hy_memory_profile_read_failed") from None
return SdkUserProfileResponse.model_validate(profile.model_dump())
return SdkUserProfileResponse(user_id=user_id, **profile.model_dump())
@app.get(
"/api/v2/user-profile/versions",
@@ -4181,15 +4296,16 @@ def create_app(
try:
async with app.state.sdk_write_coordinator.use_client() as client:
# `delete` takes no user. Another user's memory is answered
# exactly like an id that does not exist: 204, nothing
# touched, so the status cannot tell that it exists.
# exactly like an id that does not exist: 404
# `memory_not_found`, nothing touched, so the status cannot
# tell that it exists. Callers read 404 as "already gone".
current = await asyncio.to_thread(
client.get, memory_id, **_namespace_kwargs(kind, variant)
)
if current is not None and not owns(
if current is None or not owns(
HySdkMemoryRecord.from_sdk(current).user_id, user_id
):
return Response(status_code=204)
raise HTTPException(status_code=404, detail="memory_not_found")
result = await asyncio.to_thread(
client.delete, memory_id, **_namespace_kwargs(kind, variant)
)
@@ -4198,7 +4314,11 @@ def create_app(
error_code = result.get("error_code")
if error_code or result.get("partial") is True:
raise HySdkError(str(error_code or "hy_memory_delete_incomplete"))
if result.get("success") is not True and result.get("deleted_count") != 0:
if result.get("success") is not True:
# Read a moment ago, found by nobody now (a second delete won
# the race): the same answer as an id that never existed.
if result.get("deleted_count") == 0:
raise HTTPException(status_code=404, detail="memory_not_found")
raise HySdkError("hy_memory_delete_rejected")
except HTTPException:
raise
@@ -4271,6 +4391,7 @@ def create_app(
and (before is None or at < before)
)
return SdkRawPage(
user_id=user_id,
items=tuple(SdkRawItem.of(record) for record in records),
total=page.total,
offset=offset,
@@ -4303,6 +4424,7 @@ def create_app(
)
except MemoryAnswerError as error:
raise HTTPException(status_code=503, detail=str(error)) from None
answer = answer.model_copy(update={"user_id": request.user_id})
if request.question != "__om_probe__" and access is not None:
from one_memory.call_event_recorder import ask_call_event, try_record
@@ -4344,6 +4466,9 @@ def create_app(
):
if event.get("type") == "final":
final = event
elif event.get("type") == "recall":
# The first event names whose memories answer.
event = {**event, "user_id": request.user_id}
yield _sse(_serialize_ask_event(event))
except MemoryAnswerError:
yield _sse({"type": "error", "code": "memory_answer_failed"})
@@ -4422,7 +4547,7 @@ def create_app(
)
except Exception:
app.state.call_event_recording_error = True
return receipt
return receipt.model_copy(update={"user_id": request.user_id})
@app.get(
"/api/v1/memory-processing",
@@ -4565,7 +4690,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
@@ -4581,7 +4707,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.
@@ -6066,7 +6192,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)
@@ -6079,9 +6205,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,
@@ -6093,10 +6219,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)
),
)
@@ -6117,7 +6265,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"}),
@@ -155,6 +155,8 @@ class SdkContextSearchResponse(BaseModel):
used_characters: int = Field(ge=0, le=20000)
truncated: bool
total_ms: float = Field(ge=0)
#: Whose memories were searched: the request's `user_id`, else `local`.
user_id: str = Field(min_length=1, max_length=64)
class ContextLaneResult(BaseModel):
@@ -261,6 +261,7 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
used_characters=0,
truncated=True,
total_ms=(time.monotonic() - started) * 1000,
user_id=value.user_id,
)
await record(final, timed_out=True)
return final
@@ -306,6 +307,7 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
used_characters=used,
truncated=truncated,
total_ms=(time.monotonic() - started) * 1000,
user_id=value.user_id,
)
await record(final, timed_out=False)
return final
@@ -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.12"
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.12 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.
@@ -436,6 +436,11 @@ def root_identity_matches(root: Path, expected: str) -> bool:
return _root_identity_matches_details(resolved, details, expected)
def resolved_source_root(root: Path) -> Path:
"""The real directory a root names (symlinks followed), as a grant records it."""
return _root_details(root)[0]
def _check_root_identity(root: Path, expected: str | None) -> None:
if expected is not None and not root_identity_matches(root, expected):
raise SourceAccessError(SourceAccessErrorCode.SOURCE_IDENTITY_CHANGED)
@@ -27,7 +27,7 @@ def discovered_off_loop(
*,
suffixes: frozenset[str],
max_targets: int,
excluded_top_directories: frozenset[str] = frozenset(),
excluded_directory_names: frozenset[str] = frozenset(),
) -> AsyncIterator[SourceFileDiscoveryEvent]:
"""The same walk, for callers that have an event loop to keep free.
@@ -42,7 +42,7 @@ def discovered_off_loop(
grant,
suffixes=suffixes,
max_targets=max_targets,
excluded_top_directories=excluded_top_directories,
excluded_directory_names=excluded_directory_names,
),
scope_key=grant.source_id,
)
@@ -53,15 +53,17 @@ def discover_source_files(
*,
suffixes: frozenset[str],
max_targets: int,
excluded_top_directories: frozenset[str] = frozenset(),
excluded_directory_names: frozenset[str] = frozenset(),
) -> Iterator[SourceFileDiscoveryEvent]:
"""Metadata only: one lstat per candidate, no open and no per-file grant lookup.
The grant and root identity are checked once before the walk. Each listed
file keeps the grant's check, which runs again when that file is opened;
the open itself refuses symlinks and any inode other than the one listed.
`excluded_top_directories` are directories directly under the root that
are never entered, such as another user's Hermes profiles.
`excluded_directory_names` are directory names never entered, at any
depth under the root, such as the `profiles` container that holds other
users' Hermes profiles. Any depth, because the granted root may sit above
the Hermes home (`~/.hermes` inside it is then `.hermes/profiles`).
"""
grant.check_root()
inspected = 0
@@ -69,12 +71,10 @@ def discover_source_files(
grant.root,
followlinks=False,
):
at_root = Path(directory) == grant.root
child_directories[:] = sorted(
child
for child in child_directories
if not (Path(directory) / child).is_symlink()
and not (at_root and child in excluded_top_directories)
if not (Path(directory) / child).is_symlink() and child not in excluded_directory_names
)
for filename in sorted(filenames):
if Path(filename).suffix.lower() not in suffixes:
@@ -209,6 +209,7 @@ class JsonlSourceAdapter:
file_predicate: Callable[[AuthorizedSourceFile], bool] | None = None,
suffixes: frozenset[str] = frozenset({".jsonl"}),
header_selector: Callable[[dict[str, JsonValue]], bool] | None = None,
header_gate: Callable[[dict[str, JsonValue] | None], bool] | None = None,
) -> None:
if descriptor.source_id != grant.source_id:
raise ValueError("Descriptor and authorized root source IDs must match")
@@ -219,6 +220,10 @@ class JsonlSourceAdapter:
self.file_predicate = file_predicate or (lambda _: True)
self.suffixes = suffixes
self.header_selector = header_selector
# False refuses the whole file before any record is read. It reports
# and leaves no cursor, so a release that learns the format reads the
# file from the top instead of finding it already behind a checkpoint.
self.header_gate = header_gate
self._header_cache: dict[str, tuple[str, dict[str, JsonValue] | None]] = {}
self._history_listing_cache: SourceListing | None = None
@@ -371,6 +376,9 @@ class JsonlSourceAdapter:
source_file, checkpoint, header = await asyncio.to_thread(
self._prepare, listed, options
)
if self.header_gate is not None and not self.header_gate(header):
yield ScanFailure(reason_code=ScanIssueCode.UNSUPPORTED_SCHEMA)
continue
_log.debug(
"import_file_read source=%s file=%d/%d file_bytes=%d planned_bytes=%d",
self.descriptor.source_id,
@@ -574,6 +582,9 @@ class JsonlSourceAdapter:
# resumed scan starts past the header, and the records after it need
# it just as much as the first batch did.
header = await _read_header(reader, source_file, self.header_selector)
if self.header_gate is not None and not self.header_gate(header):
yield ScanFailure(reason_code=ScanIssueCode.UNSUPPORTED_SCHEMA)
continue
last: SourceCursor = _start_cursor(source_file, options, checkpoint)
async for raw in iterate_in_worker(
partial(reader, source_file, checkpoint=checkpoint),
@@ -1,3 +1,4 @@
import re
from pathlib import PurePosixPath
from pydantic import JsonValue
@@ -37,6 +38,19 @@ DEEPSEEK_HARNESS_DESCRIPTOR = SourceDescriptor(
),
)
#: One session is one file, and the name carries the format generation it was
#: written in: `session.jsonl[.zstd]` for the first format, then
#: `session.v3.jsonl.zstd` and `session.v4.jsonl.zstd`. The version segment is
#: part of the published on-disk contract, so discovery must accept it — the
#: earlier exact-name match imported none of the versioned files.
SESSION_FILE = re.compile(r"^session(?:\.v[0-9]+)?\.jsonl(?:\.zstd)?$")
#: Session generations this parser has been verified against: the first format
#: (header `version: 0`), v3 and v4. Discovery is intentionally wider than
#: parsing: a future generation is found by pattern and then skipped from its
#: header, so it is reported as unsupported instead of silently vanishing.
SUPPORTED_SESSION_VERSIONS = frozenset({0, 3, 4})
def create_deepseek_harness_adapter(
grant: AuthorizedSourceRoot,
@@ -49,25 +63,43 @@ def create_deepseek_harness_adapter(
grant=grant,
parser=parse_deepseek_harness_record,
source_version=source_version,
file_predicate=lambda source_file: (
source_file.relative_path.name in {"session.jsonl", "session.jsonl.zstd"}
),
file_predicate=lambda source_file: bool(SESSION_FILE.match(source_file.relative_path.name)),
suffixes=frozenset({".jsonl", ".zstd"}),
header_selector=_is_session_header,
header_gate=_supported_session,
)
def _is_session_header(record: dict[str, JsonValue]) -> bool:
"""Harness writes the session's `cwd` once, in the first line only.
Verified against a session this machine produced: the header is
`{"type":"session","version":0,"id":...,"cwd":"/private/tmp/dsh-verify"}`
and no later record repeats it. Read per-record, `cwd` is therefore always
absent and every turn imports with no workspace.
Verified against sessions this machine produced: the header is
`{"type":"session","version":0|4,"id":...,"cwd":...}` and no later record
repeats it. Read per-record, `cwd` is therefore always absent and every turn
imports with no workspace.
"""
return record.get("type") == "session"
def _supported_session(header: dict[str, JsonValue] | None) -> bool:
"""Whether the session's declared format generation is one we can read.
A missing header, or a header without `version`, is the first format — the
earliest files this adapter already read. Any declared version outside
`SUPPORTED_SESSION_VERSIONS` is refused rather than interpreted with today's
field rules: a future format may change field semantics, and importing it
wrongly is harder to undo than not importing it yet.
"""
if header is None:
return True
version = header.get("version")
if version is None:
return True
if not isinstance(version, int) or isinstance(version, bool):
return False
return version in SUPPORTED_SESSION_VERSIONS
def parse_deepseek_harness_record(
record: dict[str, JsonValue],
context: SourceRecordContext,
@@ -80,6 +112,11 @@ def parse_deepseek_harness_record(
)
event_type = record.get("type")
data = as_record(record.get("data"))
if not _supported_session(context.header):
return ParsedSkip(
reason_code=ScanIssueCode.UNSUPPORTED_SCHEMA,
conversation_id=conversation_id,
)
if event_type == "session":
return ParsedSkip(reason_code=ScanIssueCode.UNKNOWN_RECORD)
if data is None:
@@ -148,11 +185,18 @@ def _tool_message(
"""`tool/call` carries name and arguments; `tool/result` a user-role message.
The same call also appears as a `tool-call` block in `assistant/message`;
only the event is read, so it is stored once.
only the event is read, so it is stored once. The first format and v3 wrap
the result in `tool-result` blocks; v4 writes plain `text` blocks, so both
shapes are read through the same clipping and credential-scrubbing path.
"""
message = as_record(data.get("message"))
if record.get("type") == "tool/result" and message is not None:
text = tool_blocks_evidence(message.get("content"), block_types=frozenset({"tool-result"}))
if text is None:
text = tool_blocks_evidence(
[{"type": "tool-result", "content": message.get("content")}],
block_types=frozenset({"tool-result"}),
)
message_id = first_string(message.get("id"), data.get("callId"))
else:
text = tool_evidence_text(data)
@@ -1,9 +1,9 @@
import json
import re
import sqlite3
from collections.abc import AsyncIterator, Iterator
from collections.abc import AsyncIterator, Iterable, Iterator
from functools import partial
from pathlib import PurePosixPath
from pathlib import Path, PurePosixPath
from pydantic import JsonValue
@@ -88,7 +88,7 @@ class HermesSourceAdapter:
self.grant,
suffixes=frozenset({".jsonl", ".db"}),
max_targets=10_000,
excluded_top_directories=frozenset({HERMES_PROFILES_DIRECTORY}),
excluded_directory_names=frozenset({HERMES_PROFILES_DIRECTORY}),
):
if isinstance(item, AuthorizedSourceFile) and _is_hermes_target(
item.relative_path.as_posix()
@@ -102,7 +102,7 @@ class HermesSourceAdapter:
self.grant,
suffixes=frozenset({".jsonl", ".db"}),
max_targets=options.max_scan_targets or 100_000,
excluded_top_directories=frozenset({HERMES_PROFILES_DIRECTORY}),
excluded_directory_names=frozenset({HERMES_PROFILES_DIRECTORY}),
):
yield ScanProgressEvent(
phase=ScanPhase.DISCOVER,
@@ -253,16 +253,62 @@ def parse_hermes_rollout_record(
#: own `state.db` and sessions. A profile is another person's history: it is
#: read only through its own grant (its own MemHub user), never as part of the
#: root above it. Hermes' own export skips this directory for the same reason.
#:
#: Skipped at any depth, not only directly under the root: a root granted
#: above the Hermes home holds `.hermes/profiles/`. Hermes keeps nothing of
#: its own under a directory of that name (its history is `state.db` and
#: `sessions/`), so nothing real is lost.
HERMES_PROFILES_DIRECTORY = "profiles"
def _is_hermes_target(relative_path: str) -> bool:
path = PurePosixPath(relative_path)
if path.parts and path.parts[0] == HERMES_PROFILES_DIRECTORY:
if HERMES_PROFILES_DIRECTORY in path.parts[:-1]:
return False
return path.name == "state.db" or (path.suffix == ".jsonl" and "sessions" in path.parts)
def hermes_root_is_profiles_container(root: Path) -> bool:
"""`profiles` itself holds everyone's profiles; it is never one person's history."""
return root.name == HERMES_PROFILES_DIRECTORY
def hermes_root_conflict(mine: Path, other_users_roots: Iterable[Path]) -> str | None:
"""Why the resolved root `mine` may not be granted beside other users' Hermes roots.
Paths are compared resolved (symlinks followed; the other users' roots are
resolved here). Two roots that are equal or nest would read the same files
for two users, except when one is exactly the other's `profiles/<name>`:
the scan above never enters `profiles/`, so the two stay apart (`local` on
`~/.hermes`, a user on `~/.hermes/profiles/<name>`). None when it may.
"""
if hermes_root_is_profiles_container(mine):
return HERMES_ROOT_IS_PROFILES_CONTAINER
for other in other_users_roots:
theirs = _real(other)
if mine == theirs:
return HERMES_ROOT_OVERLAPS_OTHER_USER
for parent, child in ((mine, theirs), (theirs, mine)):
if parent in child.parents:
relative = child.relative_to(parent).parts
if len(relative) != 2 or relative[0] != HERMES_PROFILES_DIRECTORY:
return HERMES_ROOT_OVERLAPS_OTHER_USER
return None
#: 422: the root is the `profiles` container, not a profile.
HERMES_ROOT_IS_PROFILES_CONTAINER = "hermes_root_is_profiles_container"
#: 409: the root is, holds or sits inside another user's Hermes root.
HERMES_ROOT_OVERLAPS_OTHER_USER = "hermes_root_overlaps_other_user"
def _real(path: Path) -> Path:
try:
return path.expanduser().resolve(strict=False)
except (OSError, RuntimeError):
return path.expanduser().absolute()
def _read_rollout(
source_file: AuthorizedSourceFile,
options: ScanOptions,
@@ -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)
}
@@ -135,6 +135,8 @@ class RecallReceipt(BaseModel):
#: exists yet (`fallback_reason`). Raw hits are excerpts, not memories.
mode: Literal["memory", "raw"] = "memory"
fallback_reason: Literal["model_unavailable", "no_memories"] | None = None
#: Whose memories were searched: the request's `user_id`, else `local`.
user_id: str = DEFAULT_USER_ID
class RecallTarget(Protocol):
@@ -33,7 +33,7 @@ from one_memory.model_profiles import (
)
from one_memory.sdk_search_options import SdkSearchFilters, SearchLane
from one_memory.secrets import SecretStore, SecretStoreUnavailableError
from one_memory.user_scope import owns
from one_memory.user_scope import DEFAULT_USER_ID, owns
_NO_GROUNDING = "现有记忆不足以回答这个问题。"
_GROUNDING_LIMIT = 6
@@ -62,6 +62,8 @@ class SdkMemoryAnswer(BaseModel):
answer: str
memories: tuple[SdkAskMemory, ...]
mode: Literal["generated", "extractive", "empty"]
#: Whose memories answered: the request's `user_id`, else `local`.
user_id: str = DEFAULT_USER_ID
class _PromptMemory:
+2 -1
View File
@@ -25,6 +25,7 @@
| `test_c1_cross_source_session_isolation.py` | `contract` | Same session name in two sources must stay two conversations. |
| `test_c1_damage_per_source.py` | `contract` | Damage and unknown schema, per source: a bad record must not sink the import. |
| `test_c1_deepseek_profiles.py` | `contract` | DeepSeek Harness has two profiles; only the headless session form is importable. |
| `test_c1_deepseek_versioned_sessions.py` | `contract` | DeepSeek Harness versions its session file names; every generation must import. |
| `test_c1_import_event_matrix.py` | `contract` | C1 owned matrix: existing history, new file, append, truncate/replace, restart, revoke. |
| `test_c1_import_failure_injection.py` | `contract` | C1 failure injection: atomic receipt, disk-full, read-only source, rotation. |
| `test_c1_import_instrumentation.py` | `contract` | Instrumented import accounting: bytes read, opens, hashes, jobs, duplicates. |
@@ -318,4 +319,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. |
共 313 个测试文件。
共 314 个测试文件。
@@ -14,8 +14,8 @@ from pathlib import Path
import pytest
PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live")
EXPECTED_TEST_FILE_COUNT = 313
EXPECTED_TEST_FILES_SHA256 = "dcada5d5421767196a79e415dd47c2b6c6d67681972dbe81481b31867d318928"
EXPECTED_TEST_FILE_COUNT = 314
EXPECTED_TEST_FILES_SHA256 = "d745df3dfb964a8d2197c1ca3768728249d84ac4729305155ae1ef48428a19e4"
# Pure transformations, validation models, parity checks and state machines.
# This list is intentionally small: uncertain files default to contract.
@@ -0,0 +1,246 @@
"""DeepSeek Harness versions its session file names; every generation must import.
The first format writes `<session>/session.jsonl[.zstd]`; v3 and v4 write
`session.v3.jsonl.zstd` / `session.v4.jsonl.zstd`. Discovery used to match only
the two unversioned names, so every v3/v4 session was invisible to the import
job — `discovered 0` while the files sat on disk. These tests pin the file-name
contract, the header gate that keeps an unknown future version out of the
database, and the upgrade path where a library imported before the fix picks up
the versioned files on its next scan.
"""
import json
from pathlib import Path
import pytest
import zstandard
from one_memory.imports.adapters import ScanFailure, ScanIssueCode
from one_memory.imports.sources import deepseek_harness
from one_memory.imports.sources.deepseek_harness import (
DEEPSEEK_HARNESS_DESCRIPTOR,
create_deepseek_harness_adapter,
)
from tests.test_secondary_source_adapters import (
_cursors,
_evidence,
_evidence_text,
_grant,
_scan,
_skip_codes,
)
def _write_session(directory: Path, name: str, records: list[dict[str, object]]) -> None:
directory.mkdir(parents=True, exist_ok=True)
payload = "".join(f"{json.dumps(record)}\n" for record in records).encode()
(directory / name).write_bytes(zstandard.ZstdCompressor().compress(payload))
def _conversation(version: int | None, *, directory: str = "run-a") -> list[dict[str, object]]:
header: dict[str, object] = {
"type": "session",
"version": version,
"id": directory,
"cwd": "/private/versioned-project",
}
return [
header,
{
"type": "user/message",
"seq": 1,
"data": {
"id": "human",
"role": "user",
"source": {"kind": "user"},
"content": [{"type": "text", "text": "visible human"}],
},
},
{
"type": "assistant/message",
"seq": 2,
"data": {
"message": {
"id": "assistant",
"role": "assistant",
"content": [{"type": "text", "text": "visible assistant"}],
}
},
},
]
def _session_adapter(tmp_path: Path):
root = tmp_path / "dsh"
root.mkdir(parents=True, exist_ok=True)
return root, create_deepseek_harness_adapter(
_grant(DEEPSEEK_HARNESS_DESCRIPTOR.source_id, root, tmp_path)
)
def test_versioned_v4_session_file_is_discovered_and_imported(tmp_path: Path) -> None:
root, adapter = _session_adapter(tmp_path)
session = root / "run-v4"
records = _conversation(4, directory="run-v4")
records.append(
{
"type": "tool/result",
"seq": 3,
"time": 1793341068763,
"data": {
"callId": "call-1",
"message": {"role": "tool", "content": [{"type": "text", "text": "tool output"}]},
},
}
)
_write_session(session, "session.v4.jsonl.zstd", records)
# A rotated backup next to the live file must not be imported as a second copy.
(session / "session.v4.jsonl.zstd.orig-20261027").write_bytes(
zstandard.ZstdCompressor().compress(b'{"type":"session","version":4}\n')
)
events = _scan(adapter)
assert _evidence_text(events) == ["visible human", "visible assistant", "tool output"]
evidence = _evidence(events)
assert {event.evidence.project for event in evidence} == {"versioned-project"}
assert {event.evidence.conversation_id for event in evidence} == {"run-v4"}
def test_versioned_v3_session_file_is_discovered_and_imported(tmp_path: Path) -> None:
root, adapter = _session_adapter(tmp_path)
_write_session(root / "run-v3", "session.v3.jsonl.zstd", _conversation(3))
events = _scan(adapter)
assert _evidence_text(events) == ["visible human", "visible assistant"]
def _tool_result(content: list[dict[str, object]]) -> dict[str, object]:
return {
"type": "tool/result",
"seq": 3,
"data": {"callId": "call-1", "message": {"role": "tool", "content": content}},
}
def test_tool_output_imports_in_either_block_shape(tmp_path: Path) -> None:
"""`tool/result` output written as `text` blocks used to be dropped.
Only `tool-result` blocks were read, so a session that writes plain `text`
blocks lost every tool output. Both shapes must arrive, in any generation.
"""
root, adapter = _session_adapter(tmp_path)
v3 = _conversation(3, directory="run-v3-text")
v3.append(_tool_result([{"type": "text", "text": "v3 text output"}]))
_write_session(root / "run-v3-text", "session.v3.jsonl.zstd", v3)
v0 = _conversation(0, directory="run-v0-block")
v0.append(_tool_result([{"type": "tool-result", "content": "v0 block output"}]))
_write_session(root / "run-v0-block", "session.jsonl.zstd", v0)
texts = _evidence_text(_scan(adapter))
assert "v3 text output" in texts
assert "v0 block output" in texts
def test_text_tool_output_is_scrubbed_like_block_output(tmp_path: Path) -> None:
root, adapter = _session_adapter(tmp_path)
secret = "sk-" + "a" * 40
records = _conversation(4, directory="run-secret")
records.append(_tool_result([{"type": "text", "text": f"token {secret} " + "x" * 5000}]))
_write_session(root / "run-secret", "session.v4.jsonl.zstd", records)
tool = _evidence_text(_scan(adapter))[-1]
assert secret not in tool
assert len(tool) < 2000
def test_session_file_names_outside_the_contract_are_not_imported(tmp_path: Path) -> None:
root, adapter = _session_adapter(tmp_path)
for name in (
"session.v4.jsonl.zstd.orig-20261027",
"session.vx.jsonl.zstd",
"other.v4.jsonl.zstd",
"session.v4.json",
):
_write_session(root / name.replace(".", "-"), name, _conversation(4))
assert _evidence(_scan(adapter)) == []
def test_unknown_future_session_version_imports_nothing(tmp_path: Path) -> None:
root, adapter = _session_adapter(tmp_path)
records = _conversation(5)
records.append(
{
"type": "user/message",
"seq": 9,
"data": {
"id": "future",
"role": "user",
"source": {"kind": "user"},
"content": [{"type": "text", "text": "future generation payload"}],
},
}
)
_write_session(root / "run-v5", "session.v5.jsonl.zstd", records)
events = _scan(adapter)
assert _evidence(events) == []
assert ScanIssueCode.UNSUPPORTED_SCHEMA in _skip_codes(events)
def test_unknown_session_version_is_reported_and_read_once_supported(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Not importing a future format yet must stay "yet".
A cursor left behind by the refused scan would put the whole session behind
the checkpoint, so the release that learns the format would read nothing.
"""
root, adapter = _session_adapter(tmp_path)
_write_session(root / "run-v5", "session.v5.jsonl.zstd", _conversation(5))
refused = _scan(adapter)
assert _cursors(refused) == {}
assert [event.reason_code for event in refused if isinstance(event, ScanFailure)] == [
ScanIssueCode.UNSUPPORTED_SCHEMA
]
monkeypatch.setattr(deepseek_harness, "SUPPORTED_SESSION_VERSIONS", frozenset({0, 3, 4, 5}))
_, upgraded = _session_adapter(tmp_path)
assert _evidence_text(_scan(upgraded, cursors=_cursors(refused))) == [
"visible human",
"visible assistant",
]
def test_upgrade_scan_rediscovers_versioned_files_already_on_disk(tmp_path: Path) -> None:
"""A library imported before this fix must pick up the files on its next scan.
The first scan sees only the unversioned session that the old predicate
matched; after the upgrade, the versioned file next to it is discovered by
the next scan while the already imported session stays deduplicated by the
cursor it left behind.
"""
root, adapter = _session_adapter(tmp_path)
old = root / "run-old"
_write_session(old, "session.jsonl.zstd", _conversation(0, directory="run-old"))
first_scan = _scan(adapter)
assert _evidence_text(first_scan) == ["visible human", "visible assistant"]
assert _evidence(first_scan)[0].evidence.conversation_id == "run-old"
versioned = root / "run-v4"
_write_session(versioned, "session.v4.jsonl.zstd", _conversation(4, directory="run-v4"))
cursors = _cursors(first_scan)
assert cursors # the old session left a cursor behind
upgrade_scan = _scan(adapter, cursors=cursors)
assert _evidence_text(upgrade_scan) == ["visible human", "visible assistant"]
assert {event.evidence.conversation_id for event in _evidence(upgrade_scan)} == {"run-v4"}
@@ -193,7 +193,7 @@ def _qwenwork() -> Harness:
def _deepseek() -> Harness:
def write(path: Path, turns: list[Turn]) -> None:
# deepseek only accepts a file literally named session.jsonl, and nests
# deepseek only accepts files named session[.vN].jsonl[.zstd], and nests
# the turn under "data".
lines: list[dict] = [{"type": "session", "version": 0, "id": "s1", "cwd": "/w"}]
for index, (role, text, _session) in enumerate(turns):
@@ -225,7 +225,7 @@ def _deepseek() -> Harness:
from one_memory.imports.sources.deepseek_harness import create_deepseek_harness_adapter
# deepseek accepts only a file literally named session.jsonl.
# deepseek accepts only session[.vN].jsonl[.zstd]; the first format is used here.
return Harness(
"deepseek-harness",
write,
@@ -293,6 +293,7 @@ def test_sdk_user_profile_http_returns_native_assembled_profile(tmp_path):
response = await client.get("/api/v2/user-profile")
assert response.status_code == 200, response.text
assert response.json() == {
"user_id": "local",
"request_id": "profile-request",
"profile": {
"memory_id": "l0-head",
+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)
@@ -334,7 +334,9 @@ def test_another_users_memory_id_answers_404_for_read_update_and_delete(
assert (await client.get(path)).status_code == 404
assert (await client.put(path, json={"content": "stolen"})).status_code == 404
assert (await client.delete(path)).status_code == 204
refused = await client.delete(path)
assert refused.status_code == 404
assert refused.json()["detail"] == "memory_not_found"
assert sdk.memories[wife_memory]["content"] == "wife drinks coffee"
own = await client.get(path, params={"user_id": "wife"})
@@ -343,6 +345,36 @@ def test_another_users_memory_id_answers_404_for_read_update_and_delete(
assert updated.status_code == 200
assert (await client.delete(path, params={"user_id": "wife"})).status_code == 204
assert wife_memory not in sdk.memories
# A second delete (a double click) finds nothing: 404, already gone.
again = await client.delete(path, params={"user_id": "wife"})
assert again.status_code == 404
assert again.json()["detail"] == "memory_not_found"
run_with_sdk(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_a_delete_that_loses_the_race_answers_404_like_a_missing_id(tmp_path, monkeypatch) -> None:
"""Read as the caller's own, then gone before the SDK deletes it."""
async def scenario(app, sdk, client):
await _write_both_users(app, sdk, client)
[mine] = [
memory_id
for memory_id, row in sdk.memories.items()
if row["content"] == "local drinks tea"
]
original = sdk.delete
def raced(memory_id, kind=None, variant=None):
sdk.memories.pop(memory_id, None)
return original(memory_id, kind=kind, variant=variant)
sdk.delete = raced # type: ignore[method-assign]
response = await client.delete(f"/api/v2/memories/{mine}")
missing = await client.delete("/api/v2/memories/no-such-memory")
assert _observed(response) == _observed(missing)
assert response.status_code == 404
run_with_sdk(tmp_path, monkeypatch, scenario)
@@ -426,6 +458,52 @@ async def _true() -> bool:
return True
@pytest.mark.fresh_sdk
@pytest.mark.parametrize("user", [None, "wife"])
def test_every_read_echoes_the_user_it_was_read_for(tmp_path, monkeypatch, user) -> None:
"""Each read names the user it answered for: the one asked for, else `local`."""
async def scenario(app, sdk, client):
await _write_both_users(app, sdk, client)
monkeypatch.setattr(
"one_memory.api._recall_model_ready", lambda _app: _true(), raising=True
)
app.state.recall_target = _sdk_recall_target(app, "local")
expected = user or "local"
params = {} if user is None else {"user_id": user}
body = {} if user is None else {"user_id": user}
reads = [
await client.get("/api/v2/memory-library", params=params),
await client.get("/api/v2/memory-library", params={**params, "query": "route"}),
await client.get("/api/v2/memory-library/layers", params=params),
await client.get("/api/v2/user-profile", params=params),
await client.get("/api/v2/home-summary", params=params),
await client.get("/api/v2/memories/graph", params=params),
await client.get("/api/v2/raw", params=params),
await client.post(
"/api/v2/context/search", json={"query": "drinks", "kinds": ["memory"], **body}
),
await client.post("/api/v1/recall", json={"query": "drinks", **body}),
await client.post("/api/v2/memories/ask", json={"question": "what drink", **body}),
]
for response in reads:
assert response.status_code == 200, (response.request.url, response.text)
assert response.json()["user_id"] == expected, response.request.url
async with client.stream(
"POST", "/api/v2/memories/ask/stream", json={"question": "what drink", **body}
) as streamed:
events = [
json.loads(line[5:])
async for line in streamed.aiter_lines()
if line.startswith("data:")
]
assert events[0]["type"] == "recall"
assert events[0]["user_id"] == expected
run_with_sdk(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
@pytest.mark.parametrize(
("method", "path", "kwargs"),
@@ -569,6 +647,26 @@ def test_a_hermes_root_scan_never_reads_its_profiles(tmp_path) -> None:
assert _scan_texts(wife, tmp_path) == ["I am the wife", "hello", "wife's transcript"]
def test_a_profiles_directory_is_skipped_at_any_depth(tmp_path) -> None:
"""A root above the Hermes home still never reads `.hermes/profiles/**`."""
home = tmp_path / "home"
hermes, _wife = _hermes_homes(home)
(hermes / "sessions").mkdir()
(hermes / "sessions" / "own.jsonl").write_text(
json.dumps({"type": "message", "role": "user", "content": "own transcript"}) + "\n"
)
nested = hermes / "sessions" / "profiles" / "kid" / "sessions"
nested.mkdir(parents=True)
(nested / "k.jsonl").write_text(
json.dumps({"type": "message", "role": "user", "content": "kid's transcript"}) + "\n"
)
above = _scan_texts(home, tmp_path)
assert "I am the wife" not in above and "kid's transcript" not in above
assert set(above) == {"I am the husband", "hi", "own transcript"}
assert "kid's transcript" not in _scan_texts(hermes, tmp_path)
def _jobs(database: Path) -> list[sqlite3.Row]:
with sqlite3.connect(database) as db:
db.row_factory = sqlite3.Row
@@ -719,6 +817,87 @@ def test_a_migrated_home_with_the_old_grant_triggers_can_connect_and_revoke(tmp_
asyncio.run(scenario())
async def _connect(client, user_id: str, root: Path | None = None, **extra):
body: dict[str, object] = {"source_ids": ["hermes"], "request_id": str(uuid4()), **extra}
if user_id != "local":
body["user_id"] = user_id
if root is not None:
body["root"] = str(root)
return await client.post("/api/v1/sources/connect", json=body)
@pytest.mark.fresh_sdk
def test_a_hermes_root_that_is_the_profiles_container_is_refused(tmp_path) -> None:
async def scenario():
home = tmp_path / "home"
hermes, _wife = _hermes_homes(home)
app = application(tmp_path, home=home)
async with app.router.lifespan_context(app), client_for(app) as client:
for user in ("wife", "local"):
refused = await _connect(client, user, hermes / "profiles")
assert refused.status_code == 422, refused.text
assert refused.json()["detail"] == "hermes_root_is_profiles_container"
assert app.state.source_grant_store.grants() == ()
asyncio.run(scenario())
@pytest.mark.fresh_sdk
def test_two_users_on_one_hermes_root_or_nested_roots_are_refused(tmp_path) -> None:
async def scenario():
home = tmp_path / "home"
hermes, wife = _hermes_homes(home)
(wife / "sessions").mkdir()
link = home / "hermes-link"
link.symlink_to(hermes, target_is_directory=True)
app = application(tmp_path, home=home)
async with app.router.lifespan_context(app), client_for(app) as client:
location = (await client.post("/api/v1/sources/hermes/location")).json()
local = await _connect(client, "local", source_roots={"hermes": location["root_id"]})
assert local.status_code == 200, local.text
# Same root, the same root through a symlink, a root inside it
# that is not `profiles/<name>`, and a root above it.
for root in (hermes, link, wife / "sessions", home):
refused = await _connect(client, "kid", root)
assert refused.status_code == 409, (root, refused.text)
assert refused.json()["detail"] == "hermes_root_overlaps_other_user"
# The standard layout: `local` on ~/.hermes, wife on its profile.
accepted = await _connect(client, "wife", wife)
assert accepted.status_code == 200, accepted.text
# Another user on wife's own root is refused too.
twin = await _connect(client, "wife2", wife)
assert twin.status_code == 409
# Once wife's grant is revoked her root is free again.
await client.post("/api/v1/sources/hermes/revoke", params={"user_id": "wife"})
assert (await _connect(client, "wife2", wife)).status_code == 200
asyncio.run(scenario())
@pytest.mark.fresh_sdk
def test_local_cannot_take_another_users_hermes_root(tmp_path) -> None:
"""The check runs for `local`'s own connect too, not only for other users."""
async def scenario():
home = tmp_path / "home"
hermes, _wife = _hermes_homes(home)
other = home / "elsewhere" / ".hermes"
_hermes_state_db(other, [(1, "s", "user", "elsewhere")])
app = application(tmp_path, home=home)
async with app.router.lifespan_context(app), client_for(app) as client:
# wife took ~/.hermes itself, which is `local`'s default root.
taken = await _connect(client, "wife", hermes)
assert taken.status_code == 200, taken.text
location = (await client.post("/api/v1/sources/hermes/location")).json()
refused = await _connect(client, "local", source_roots={"hermes": location["root_id"]})
assert refused.status_code == 409, refused.text
assert refused.json()["detail"] == "hermes_root_overlaps_other_user"
# `local` on a root of its own that does not overlap is fine.
assert (await _connect(client, "local", other)).status_code == 200
asyncio.run(scenario())
@pytest.mark.fresh_sdk
def test_only_hermes_takes_another_user_or_a_custom_root(tmp_path) -> None:
async def scenario():
@@ -852,7 +1031,7 @@ def test_another_users_id_answers_exactly_like_an_id_that_does_not_exist(
other = await client.request(method, theirs, **kwargs)
absent = await client.request(method, missing, **kwargs)
assert _observed(other) == _observed(absent), (method, theirs)
assert other.status_code == (204 if method == "DELETE" else 404)
assert other.status_code == 404
assert sdk.memories == before
run_with_sdk(tmp_path, monkeypatch, scenario)
@@ -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.12"
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.12"
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/44/19/bcd0de44ae2011ee1ad70579996aad72e18f0ca292df22d1545db05db850/hy_memory-2.0.12.tar.gz", hash = "sha256:c1231596b4cf12595775541f81680d4819461cb69e4aa4dc449f876d4c3b6742", size = 1246969, upload-time = "2026-11-02T06:30:37.448Z" }
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/75/9e/b64c7643c1bad1ddca4623a39bb62423bed0c22e7fd0d86be1ff73b2a17a/hy_memory-2.0.12-py3-none-any.whl", hash = "sha256:7a2332288197fafb89d5037eac7fef97df4c80794ead18159d21da84cde431df", size = 1316922, upload-time = "2026-11-02T06:30:35.105Z" },
]
[[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.12" },
{ name = "keyring", specifier = ">=25.7.0" },
{ name = "numpy", specifier = "==2.5.2" },
{ name = "onnxruntime", specifier = "==1.23.2" },
@@ -9,7 +9,7 @@ import type {
Tray,
} from "electron";
import { describe, expect, it, vi } from "vitest";
import { installBackgroundTray } from "./background-tray";
import { installBackgroundTray, type RuntimeHosting } from "./background-tray";
import type { LoginPreference } from "./login-preference";
async function flush() {
@@ -27,6 +27,7 @@ function fixture(
status: "disabled",
};
let changed: (() => void) | undefined;
let hosting: RuntimeHosting | Error = "background-service";
const languageSnapshot: LanguageSnapshot = {
preference: "zh-Hans",
locale: "zh-Hans",
@@ -50,6 +51,10 @@ function fixture(
};
return state;
});
const readRuntimeHosting = vi.fn(async () => {
if (hosting instanceof Error) throw hosting;
return hosting;
});
const open = vi.fn();
const reportError = vi.fn();
const install = () =>
@@ -77,6 +82,7 @@ function fixture(
reportError,
readLoginPreference,
setLoginPreference,
readRuntimeHosting,
...(trial
? { allowLoginStartup: false, productName: "One Memory Trial" }
: {}),
@@ -91,6 +97,7 @@ function fixture(
menus,
readLoginPreference,
setLoginPreference,
readRuntimeHosting,
open,
reportError,
install,
@@ -103,6 +110,9 @@ function fixture(
setState(value: LoginPreference) {
state = value;
},
setHosting(value: RuntimeHosting | Error) {
hosting = value;
},
};
}
@@ -179,6 +189,50 @@ describe("native macOS background tray", () => {
expect(f.tray.eventNames()).toEqual([]);
});
it("says quitting stops the memory service when the App hosts it", async () => {
const f = fixture();
f.setHosting("app-hosted");
f.install();
await flush();
expect(f.menu()[3]?.label).toBe("退出 MemHub(同时停止记忆服务)");
f.changeLanguage("es");
await flush();
expect(f.menu()[3]?.label).toBe(
"Salir de MemHub (también detiene el servicio de memoria)",
);
});
it("re-reads who hosts the Runtime every time the menu opens", async () => {
const f = fixture();
f.install();
await flush();
expect(f.menu()[3]?.label).toContain("后台服务继续运行");
// The user turned the background service off in Settings.
f.setHosting("app-hosted");
f.tray.emit("mouse-down");
await flush();
expect(f.menu()[3]?.label).toContain("同时停止记忆服务");
f.setHosting("background-service");
f.tray.emit("right-click");
await flush();
expect(f.menu()[3]?.label).toContain("后台服务继续运行");
expect(f.readRuntimeHosting).toHaveBeenCalledTimes(3);
});
it("never claims quit stops the service when the state is unknown", async () => {
const f = fixture();
f.setHosting(new Error("launchctl timed out"));
f.install();
await flush();
expect(f.menu()[3]?.label).toContain("后台服务继续运行");
f.setHosting("unknown");
f.tray.emit("mouse-down");
await flush();
expect(f.menu()[3]?.label).toContain("后台服务继续运行");
});
it("blocks login changes for trial, development and non-macOS builds", async () => {
for (const f of [
fixture(true, "darwin", true),
+16 -1
View File
@@ -10,6 +10,8 @@ import type {
import type { LoginPreference } from "./login-preference";
import type { NativeLanguageSource } from "./native-language";
export type RuntimeHosting = "background-service" | "app-hosted" | "unknown";
export const LOGIN_SERVICE_NAME = "com.tencent.onememory.runtime.plist";
interface TrayOptions {
@@ -24,6 +26,9 @@ interface TrayOptions {
reportError(message: string): void;
readLoginPreference(): Promise<LoginPreference>;
setLoginPreference(enabled: boolean): Promise<LoginPreference>;
/** Who keeps the Runtime alive, re-read on every menu refresh: the
* background service (quit leaves it running) or this App (quit stops it). */
readRuntimeHosting?(): Promise<RuntimeHosting>;
allowLoginStartup?: boolean;
productName?: string;
language?: NativeLanguageSource;
@@ -67,6 +72,13 @@ export function installBackgroundTray(options: TrayOptions): () => void {
} else {
label += t("nativeDevelopmentUnavailable");
}
let hosting: RuntimeHosting = "unknown";
try {
hosting = (await options.readRuntimeHosting?.()) ?? "unknown";
} catch {
hosting = "unknown";
}
if (disposed || current !== generation) return;
tray.setContextMenu(
options.buildMenu([
{ label: t("nativeOpen"), click: options.open },
@@ -92,7 +104,10 @@ export function installBackgroundTray(options: TrayOptions): () => void {
},
{ type: "separator" },
{
label: t("nativeQuit"),
// Unknown keeps the plain label: quit then stops nothing.
label: t(
hosting === "app-hosted" ? "nativeQuitStopsService" : "nativeQuit",
),
click: () => {
if (!disposed) app.quit();
},
@@ -5,6 +5,7 @@ import {
claimRuntimeServiceForApp,
claimRuntimeServiceForCli,
enableRuntimeService,
type RuntimeServiceState,
runtimeServiceEnableMarkerPath,
} from "@one-memory/runtime-control";
import { expect, it } from "vitest";
@@ -98,3 +99,25 @@ it("rejects unsupported and unconfirmed writes", async () => {
),
).rejects.toThrow("login_preference_unsupported");
});
it("reads a launchctl-disabled service as off and not switchable, asked fresh", async () => {
const dataDir = await appOwned();
let state: RuntimeServiceState = {
state: "disabled",
reason: "service-disabled-by-launchctl",
jobPid: null,
};
const serviceState = async () => state;
await expect(
readLoginPreference(dataDir, "darwin", true, serviceState),
).resolves.toEqual({
supported: false,
enabled: false,
status: "disabled-by-system",
});
state = { state: "enabled", reason: "service-enabled", jobPid: 4321 };
await expect(
readLoginPreference(dataDir, "darwin", true, serviceState),
).resolves.toEqual({ supported: true, enabled: true, status: "enabled" });
});
+15 -1
View File
@@ -1,6 +1,7 @@
import {
disableRuntimeService,
enableRuntimeService,
type RuntimeServiceState,
readRuntimeServiceOwner,
runtimeServiceEnabled,
setAppRuntimeStartAtLogin,
@@ -12,10 +13,16 @@ export interface LoginPreference {
status: string;
}
/** "Launch at login" is the background service switch: it writes the enable
* marker the LaunchAgent's `KeepAlive` waits on. On it means the Runtime
* outlives the App and quitting leaves it running; off means quitting stops
* it. `serviceState`, when given, is asked fresh so a `launchctl disable`
* override (which the switch cannot undo) reads as off, not as on. */
export async function readLoginPreference(
dataDir: string,
platform: NodeJS.Platform,
supported: boolean,
serviceState?: () => Promise<RuntimeServiceState>,
): Promise<LoginPreference> {
if (platform !== "darwin" || !supported) {
return { supported: false, enabled: false, status: "unsupported" };
@@ -28,6 +35,12 @@ export async function readLoginPreference(
if (owner?.owner !== "app") {
return { supported: false, enabled: false, status: "not-installed" };
}
if (
serviceState &&
(await serviceState()).reason === "service-disabled-by-launchctl"
) {
return { supported: false, enabled: false, status: "disabled-by-system" };
}
return {
supported: true,
enabled: owner.start_at_login && (await runtimeServiceEnabled(dataDir)),
@@ -46,6 +59,7 @@ export async function setLoginPreference(
platform: NodeJS.Platform,
input: unknown,
supported: boolean,
serviceState?: () => Promise<RuntimeServiceState>,
): Promise<LoginPreference> {
if (
!input ||
@@ -63,5 +77,5 @@ export async function setLoginPreference(
await setAppRuntimeStartAtLogin(dataDir, input.openAtLogin);
if (input.openAtLogin) await enableRuntimeService(dataDir);
else await disableRuntimeService(dataDir);
return readLoginPreference(dataDir, platform, supported);
return readLoginPreference(dataDir, platform, supported, serviceState);
}
+140 -21
View File
@@ -23,7 +23,9 @@ import {
type HostPermissionClient,
installPackagedRuntime,
installUserService,
queryRuntimeServiceState,
RecallBrokerSupervisor,
type RuntimeServiceState,
RuntimeSupervisor,
readInstalledRuntimeSource,
readPublishedCorePort,
@@ -110,6 +112,7 @@ import { showMigrationFailureDialog } from "./migration-failure-dialog";
import { isAllowedNavigation } from "./navigation-policy";
import { installDenyAllPermissionPolicy } from "./permissions";
import { isHostPresent, recallHosts } from "./recall-hosts";
import { reconcileRunningRuntime } from "./runtime-adoption";
import { shutdownRuntime } from "./runtime-shutdown";
import { createSourceChangeHandler } from "./source-change-handler";
import { SourceDirectoryPicker } from "./source-directory-picker";
@@ -140,7 +143,12 @@ let runtimeSupervisor: RuntimeSupervisor | null = null;
// Where the Runtime keeps its receipt. Stopping it on a full quit needs this,
// and `stop` runs outside `initialize`, where the directory is chosen.
let runtimeDataDir: string | null = null;
let preserveManagedRuntimeOnQuit = false;
// Whether quitting stops the Runtime is asked of the service manager at quit
// time (`currentRuntimeServiceState`), never remembered from startup. The one
// startup fact kept is a Runtime whose build could not be read: this App never
// stops one it could not compare with its own.
let preserveRuntimeOnQuitReason: string | null = null;
let runtimeServiceManagedByApp = false;
let disposeTray: (() => void) | null = null;
let disposeApplicationMenu: (() => void) | null = null;
let languagePreferences: LanguagePreferences | null = null;
@@ -247,6 +255,25 @@ if (trialProfile) {
app.setPath("userData", hourlyUserDataDir);
}
/** Ask the service manager, now, whether the background service for this data
* dir is on. Never cached: quit and the tray each re-read it, so a switch made
* in Settings, by `memhub service`, or by `launchctl` counts immediately. */
function currentRuntimeServiceState(): Promise<RuntimeServiceState> {
if (!runtimeDataDir) {
return Promise.resolve({
state: "unknown",
reason: "service-state-query-failed",
jobPid: null,
});
}
return queryRuntimeServiceState({
dataDir: runtimeDataDir,
home: app.getPath("home"),
platform: process.platform,
uid: process.getuid?.(),
});
}
function prepareProtocol(): void {
protocol.registerSchemesAsPrivileged([
{
@@ -498,6 +525,7 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
!preview &&
(hostIntegrationEnabled || desktopTestIsolation !== null) &&
installedApplication;
runtimeServiceManagedByApp = appManagedRuntime;
const runningPackagedHost =
app.isPackaged && !isolatedHourly
? await resolveRunningPackagedHostPaths({
@@ -594,9 +622,11 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
throw new Error("host_permission_store_unavailable");
}
if (isQuitting()) return;
const hosts = (await recallHosts(app.getPath("home"))).filter(
(host) => host.hostId !== "openclaw",
);
const scanHosts = async () =>
(await recallHosts(app.getPath("home"))).filter(
(host) => host.hostId !== "openclaw",
);
const hosts = await scanHosts();
readDiagnosticSummary = async () => {
const core = coreSupervisor;
if (!core) return {};
@@ -851,14 +881,25 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
);
ipcMain.handle("one-memory:get-login-preference", (event) => {
assertAllowedRenderer(event.senderFrame?.url);
return readLoginPreference(dataDir, process.platform, appManagedRuntime);
return readLoginPreference(
dataDir,
process.platform,
appManagedRuntime,
currentRuntimeServiceState,
);
});
ipcMain.handle("one-memory:set-login-preference", (event, input: unknown) => {
assertAllowedRenderer(event.senderFrame?.url);
return observeDesktopAction(
"login_preference",
() =>
setLoginPreference(dataDir, process.platform, input, appManagedRuntime),
setLoginPreference(
dataDir,
process.platform,
input,
appManagedRuntime,
currentRuntimeServiceState,
),
{ action: "set" },
);
});
@@ -876,10 +917,14 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
});
ipcMain.handle("one-memory:recall-hosts", async (event) => {
assertAllowedRenderer(event.senderFrame?.url);
// A Harness profile can appear at any time, and which ones exist is only
// knowable from the disk. In place, so `requireHost` sees it too. One
// directory read per list, next to an IPC round trip per host below.
hosts.splice(0, hosts.length, ...(await scanHosts()));
if (connectionClient?.host) {
const inspector = createConnectionClient({ dataDir });
const rows: unknown[] = [];
for (const host of hosts) {
for (const host of [...hosts]) {
const inspected = await inspector.host?.({
hostId: host.hostId,
operation: "inspect",
@@ -1169,7 +1214,6 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
const startAtLogin = owner?.owner === "app" ? owner.start_at_login : true;
const previousOwner = owner;
let runtimeChanged = false;
let transientRuntime = false;
// Install whoever owns the service. Owner decides who starts and stops
// it, not which build runs: skipping the install for a CLI owner (also
// the App's own guess when its receipt is missing) left an upgraded App
@@ -1239,12 +1283,68 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
const frozen = await readSdkMigrationBlock(dataDir);
if (frozen?.frozen !== undefined) throw new Error(frozen.code);
}
const alreadyRunning = runtimeChanged
? false
: await waitForRuntimeProcess({ dataDir, attempts: 4 }).then(
() => true,
() => false,
);
// One Runtime per data dir. A Runtime already serving it is adopted
// when it is this App's build or newer (silently; a newer one is never
// stopped or downgraded at startup); an older one is replaced (through
// launchd when it is the service's job), and one whose build cannot be
// read is adopted but never stopped.
const reconciled = await reconcileRunningRuntime({
app: {
...(packagedBuildNumber === undefined
? {}
: { buildNumber: packagedBuildNumber }),
...(packagedCommit === undefined ? {} : { commit: packagedCommit }),
},
installedChanged: runtimeChanged,
findRunning: () =>
waitForRuntimeProcess({ dataDir, attempts: 4 }).then(
(attachment) =>
attachment.servicePid === null
? null
: {
pid: attachment.servicePid,
build: attachment.runtimeBuild,
},
() => null,
),
queryServiceState: currentRuntimeServiceState,
stopRuntime: () => stopRuntimeProcess({ dataDir }),
log: ({ decision, service, running }) =>
diagnostics.log({
level: decision.action === "adopt-unverified" ? "warn" : "info",
component: "desktop.stable_runtime",
event: "stable_runtime.adoption",
outcome:
decision.action === "adopt-unverified" ? "degraded" : "succeeded",
details: {
action: decision.action,
reason:
"reason" in decision
? decision.reason
: (service?.reason ?? "no-runtime-running"),
status: service?.state ?? "not-queried",
version:
running === null
? "none"
: running.build === undefined
? "legacy"
: String(running.build.buildNumber ?? "unknown"),
},
}),
});
preserveRuntimeOnQuitReason = reconciled.preserveReason;
if (reconciled.decision.action === "adopt-unverified") {
void dialog
.showMessageBox({
type: "warning",
message: "MemHub",
detail: stableRuntimeFailureDetail(
new Error("runtime_build_unknown"),
),
})
.catch(() => undefined);
}
const alreadyRunning = reconciled.adopted;
if (owner?.owner !== "cli") {
const appBundle = path.resolve(path.dirname(process.execPath), "../..");
owner = await claimRuntimeServiceForApp({
@@ -1258,8 +1358,13 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
// A durable `launchctl disable` override is a state to report, not a
// failure to undo: `installUserService` never re-enables a service the
// user or the system disabled. An explicit App launch may still own a
// transient Runtime for this UI session. Shutdown stops that process.
const serviceMode = await installServiceWithDisabledFallback({
// transient Runtime for this UI session; quit stops it because the
// service is disabled, which it decides by asking launchd again then.
// For an enabled service, `installUserService` restarts the job with
// `kickstart -k` (or re-bootstraps a changed plist), so an older
// Runtime is replaced through launchd rather than killed into a
// respawn of the old binary.
await installServiceWithDisabledFallback({
install: async () => {
await installUserService(layout, {
uid: process.getuid?.(),
@@ -1291,12 +1396,10 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
});
},
});
transientRuntime = serviceMode === "transient";
}
if (owner?.owner === "app" && !startAtLogin) {
await disableRuntimeService(dataDir);
}
preserveManagedRuntimeOnQuit = !transientRuntime;
} else if (standaloneRuntime) {
if (
!desktopBackgroundLaunch &&
@@ -1665,7 +1768,8 @@ if (desktopVersion !== null) {
? readLoginPreference(
runtimeDataDir,
process.platform,
preserveManagedRuntimeOnQuit,
runtimeServiceManagedByApp,
currentRuntimeServiceState,
)
: Promise.resolve({
supported: false,
@@ -1678,9 +1782,21 @@ if (desktopVersion !== null) {
runtimeDataDir,
process.platform,
{ openAtLogin: enabled, userConfirmed: true },
preserveManagedRuntimeOnQuit,
runtimeServiceManagedByApp,
currentRuntimeServiceState,
);
},
readRuntimeHosting: async () => {
if (!runtimeDataDir || !app.isPackaged) return "unknown";
// A Runtime whose build could not be read is never stopped by it.
if (preserveRuntimeOnQuitReason) return "unknown";
const service = await currentRuntimeServiceState();
return service.state === "enabled"
? "background-service"
: service.state === "disabled"
? "app-hosted"
: "unknown";
},
...(trialProfile
? { allowLoginStartup: false, productName: app.getName() }
: {}),
@@ -1712,7 +1828,10 @@ if (desktopVersion !== null) {
// stop and says so by taking the embedded path instead.
dataDir: runtimeDataDir ?? "",
standalone: app.isPackaged && runtimeDataDir !== null,
preserveManagedRuntime: preserveManagedRuntimeOnQuit,
// Asked now, not remembered from startup: the user may have switched
// the background service on or off since.
queryServiceState: currentRuntimeServiceState,
preserveReason: preserveRuntimeOnQuitReason,
stopSupervisor: async () => {
const results = await Promise.allSettled([
runtimeSupervisor?.stop() ?? coreSupervisor?.stop(),
@@ -0,0 +1,233 @@
import type { RuntimeServiceState } from "@one-memory/runtime-control";
import { describe, expect, it, vi } from "vitest";
import {
compareRuntimeBuild,
decideRuntimeAdoption,
type RunningRuntime,
reconcileRunningRuntime,
} from "./runtime-adoption";
const app = { buildNumber: 38, commit: "abcdef1234567" };
const enabled = (jobPid: number | null): RuntimeServiceState => ({
state: "enabled",
reason: "service-enabled",
jobPid,
});
const disabled: RuntimeServiceState = {
state: "disabled",
reason: "service-disabled-by-launchctl",
jobPid: null,
};
const unknown: RuntimeServiceState = {
state: "unknown",
reason: "service-state-timeout",
jobPid: null,
};
const running = (
build: RunningRuntime["build"],
pid = 4321,
): RunningRuntime => ({ pid, build });
function reconcile(options: {
running: RunningRuntime | null;
service: RuntimeServiceState;
installedChanged?: boolean;
}) {
const stopRuntime = vi.fn(async () => "stopped");
const queryServiceState = vi.fn(async () => options.service);
const result = reconcileRunningRuntime({
app,
installedChanged: options.installedChanged ?? false,
findRunning: async () => options.running,
queryServiceState,
stopRuntime,
});
return { result, stopRuntime, queryServiceState };
}
describe("comparing the running Runtime's build with the App's", () => {
it("compares build numbers, then commits both sides know", () => {
expect(compareRuntimeBuild(app, { buildNumber: 38, commit: null })).toBe(
"same",
);
expect(
compareRuntimeBuild(app, { buildNumber: 38, commit: "abcdef1" }),
).toBe("same");
expect(
compareRuntimeBuild(app, { buildNumber: 38, commit: "0123456" }),
).toBe("app-newer");
expect(compareRuntimeBuild(app, { buildNumber: 37, commit: null })).toBe(
"app-newer",
);
expect(compareRuntimeBuild(app, { buildNumber: 39, commit: null })).toBe(
"runtime-newer",
);
});
it("reads a receipt without build fields as a Runtime older than this App", () => {
expect(compareRuntimeBuild(app, undefined)).toBe("app-newer");
});
it("is unknown when either side cannot say", () => {
expect(compareRuntimeBuild(app, { buildNumber: null, commit: null })).toBe(
"unknown",
);
expect(compareRuntimeBuild({}, { buildNumber: 38, commit: null })).toBe(
"unknown",
);
});
});
describe("App start adopts or replaces a running Runtime", () => {
it("starts one when nothing runs, without asking the service manager", async () => {
const { result, stopRuntime, queryServiceState } = reconcile({
running: null,
service: enabled(null),
});
await expect(result).resolves.toMatchObject({
decision: { action: "start" },
adopted: false,
});
expect(queryServiceState).not.toHaveBeenCalled();
expect(stopRuntime).not.toHaveBeenCalled();
});
it("adopts the same build instead of starting a second Runtime", async () => {
const { result, stopRuntime } = reconcile({
running: running({ buildNumber: 38, commit: null }),
service: disabled,
});
await expect(result).resolves.toEqual({
decision: { action: "adopt" },
adopted: true,
preserveReason: null,
});
expect(stopRuntime).not.toHaveBeenCalled();
});
it("adopts the service's own job when the service is enabled", async () => {
const { result, stopRuntime } = reconcile({
running: running({ buildNumber: 38, commit: null }, 4321),
service: enabled(4321),
});
await expect(result).resolves.toMatchObject({
decision: { action: "adopt" },
adopted: true,
});
expect(stopRuntime).not.toHaveBeenCalled();
});
it("ends with one Runtime when a stray holds the lease beside an enabled service", async () => {
const { result, stopRuntime } = reconcile({
running: running({ buildNumber: 38, commit: null }, 777),
service: enabled(null),
});
await expect(result).resolves.toMatchObject({
decision: { action: "stop-then-start", reason: "stray-runtime" },
adopted: false,
});
expect(stopRuntime).toHaveBeenCalledOnce();
});
it("does not treat a Runtime as a stray when the service state is unknown", async () => {
const { result, stopRuntime } = reconcile({
running: running({ buildNumber: 38, commit: null }, 777),
service: unknown,
});
await expect(result).resolves.toMatchObject({
decision: { action: "adopt" },
});
expect(stopRuntime).not.toHaveBeenCalled();
});
it("replaces an older Runtime that is the service's job through the service manager", async () => {
const { result, stopRuntime } = reconcile({
running: running({ buildNumber: 37, commit: null }, 4321),
service: enabled(4321),
});
await expect(result).resolves.toMatchObject({
decision: { action: "replace-via-service" },
adopted: false,
preserveReason: null,
});
// No plain kill: launchd would answer it with a respawn of the old job.
expect(stopRuntime).not.toHaveBeenCalled();
});
it("replaces a legacy service job whose receipt has no build through launchd", async () => {
const { result, stopRuntime } = reconcile({
running: running(undefined, 4321),
service: enabled(4321),
});
await expect(result).resolves.toMatchObject({
decision: { action: "replace-via-service" },
});
expect(stopRuntime).not.toHaveBeenCalled();
});
it("stops an older transient or CLI-started Runtime, then starts the right one", async () => {
const { result, stopRuntime } = reconcile({
running: running({ buildNumber: 36, commit: null }, 999),
service: disabled,
});
await expect(result).resolves.toMatchObject({
decision: { action: "stop-then-start", reason: "app-newer" },
adopted: false,
});
expect(stopRuntime).toHaveBeenCalledOnce();
});
it("replaces a same-build Runtime when this launch moved `current`", () => {
expect(
decideRuntimeAdoption({
running: running({ buildNumber: 38, commit: null }, 4321),
app,
service: enabled(4321),
installedChanged: true,
}),
).toEqual({ action: "replace-via-service" });
});
it("adopts a newer Runtime silently and leaves quit to the service state", async () => {
const { result, stopRuntime } = reconcile({
running: running({ buildNumber: 40, commit: null }, 999),
service: disabled,
});
// No special quit pass: with the service disabled, quit stops it like any
// adopted Runtime; with it enabled, quit preserves it.
await expect(result).resolves.toEqual({
decision: { action: "adopt-newer" },
adopted: true,
preserveReason: null,
});
expect(stopRuntime).not.toHaveBeenCalled();
});
it("never stops or downgrades a newer Runtime at startup, even beside an enabled service", async () => {
const { result, stopRuntime } = reconcile({
running: running({ buildNumber: 40, commit: null }, 999),
service: enabled(4321),
installedChanged: true,
});
await expect(result).resolves.toMatchObject({
decision: { action: "adopt-newer" },
adopted: true,
});
expect(stopRuntime).not.toHaveBeenCalled();
});
it("never kills a Runtime whose build cannot be read", async () => {
const { result, stopRuntime } = reconcile({
running: running({ buildNumber: null, commit: null }, 999),
service: disabled,
installedChanged: true,
});
await expect(result).resolves.toEqual({
decision: { action: "adopt-unverified" },
adopted: true,
preserveReason: "runtime-build-unknown",
});
expect(stopRuntime).not.toHaveBeenCalled();
});
});
+158
View File
@@ -0,0 +1,158 @@
import type {
RuntimeReceiptBuild,
RuntimeServiceState,
} from "@one-memory/runtime-control";
/** The build this App carries. */
export interface AppBuild {
buildNumber?: number;
commit?: string;
}
/** The Runtime found serving the data dir when the App starts. */
export interface RunningRuntime {
pid: number;
/** As published in its receipt; `undefined` for a receipt without the
* fields, which only a Runtime older than this App writes. */
build: RuntimeReceiptBuild | undefined;
}
export type RuntimeVersionRelation =
| "same"
| "app-newer"
| "runtime-newer"
| "unknown";
export type RuntimeAdoption =
/** Nothing is running: install or start this App's Runtime. */
| { action: "start" }
/** Same build: use it as it is. */
| { action: "adopt" }
/** Its build cannot be compared: use it, never stop it, tell the user. */
| { action: "adopt-unverified" }
/** Older, and it is the service manager's job: replace it through the
* service manager, never with a plain kill launchd would answer with a
* respawn of whatever it last loaded. */
| { action: "replace-via-service" }
/** Older and transient/CLI-started, or a stray beside an enabled service:
* stop it gracefully, then install or start the right one. */
| { action: "stop-then-start"; reason: "app-newer" | "stray-runtime" }
/** Newer than this App: use it as it is, silently. Never stopped or
* downgraded at startup and never joined by a second Runtime; quit treats
* it like any adopted Runtime. */
| { action: "adopt-newer" };
/** Compare by build number, then by commit when both sides know theirs. */
export function compareRuntimeBuild(
app: AppBuild,
runtime: RuntimeReceiptBuild | undefined,
): RuntimeVersionRelation {
if (app.buildNumber === undefined) return "unknown";
// Every Runtime that publishes its build carries the fields, even as null.
// A receipt without them predates that contract, and so this App.
if (runtime === undefined) return "app-newer";
if (runtime.buildNumber === null) return "unknown";
if (runtime.buildNumber < app.buildNumber) return "app-newer";
if (runtime.buildNumber > app.buildNumber) return "runtime-newer";
const appCommit = app.commit?.trim().toLowerCase();
if (
appCommit &&
runtime.commit &&
!appCommit.startsWith(runtime.commit) &&
!runtime.commit.startsWith(appCommit)
) {
// Same build, different commit: the App's bundle takes over, exactly as
// `installPackagedRuntime({ replaceSameBuild: true })` already does.
return "app-newer";
}
return "same";
}
export function decideRuntimeAdoption(input: {
running: RunningRuntime | null;
app: AppBuild;
service: RuntimeServiceState;
/** This launch just moved `current` to the App's bundled Runtime, so the
* process still running was started from the install it replaced. */
installedChanged?: boolean;
}): RuntimeAdoption {
const { running, service } = input;
if (running === null) return { action: "start" };
let relation = compareRuntimeBuild(input.app, running.build);
if (relation === "same" && input.installedChanged) relation = "app-newer";
if (relation === "runtime-newer") return { action: "adopt-newer" };
if (relation === "unknown") return { action: "adopt-unverified" };
const serviceJob = service.jobPid !== null && service.jobPid === running.pid;
if (relation === "app-newer") {
return serviceJob
? { action: "replace-via-service" }
: { action: "stop-then-start", reason: "app-newer" };
}
// One Runtime per data dir: with the service on, the one that stays is the
// service's own job, not a transient or CLI process holding its lease.
if (service.state === "enabled" && !serviceJob) {
return { action: "stop-then-start", reason: "stray-runtime" };
}
return { action: "adopt" };
}
export interface RuntimeReconcileDependencies {
app: AppBuild;
installedChanged?: boolean;
findRunning(): Promise<RunningRuntime | null>;
queryServiceState(): Promise<RuntimeServiceState>;
/** Graceful stop of the one verified Runtime: in-flight writes finish. */
stopRuntime(): Promise<unknown>;
log?(event: {
decision: RuntimeAdoption;
service: RuntimeServiceState | null;
running: RunningRuntime | null;
}): Promise<void> | void;
}
export interface RuntimeReconcileResult {
decision: RuntimeAdoption;
/** The running Runtime stays and the caller must not start another. */
adopted: boolean;
/** Quit must leave the adopted Runtime alone whatever the service state:
* this App could not prove it is its own build. */
preserveReason: string | null;
}
/** Decide what to do with a Runtime already serving the data dir at App start,
* and do the part that has to happen before the normal start path runs. */
export async function reconcileRunningRuntime(
dependencies: RuntimeReconcileDependencies,
): Promise<RuntimeReconcileResult> {
const running = await dependencies.findRunning();
const service =
running === null ? null : await dependencies.queryServiceState();
const decision = decideRuntimeAdoption({
running,
app: dependencies.app,
service: service ?? {
state: "unknown",
reason: "service-state-query-failed",
jobPid: null,
},
installedChanged: dependencies.installedChanged ?? false,
});
await dependencies.log?.({ decision, service, running });
switch (decision.action) {
case "start":
case "replace-via-service":
return { decision, adopted: false, preserveReason: null };
case "stop-then-start":
await dependencies.stopRuntime();
return { decision, adopted: false, preserveReason: null };
case "adopt":
case "adopt-newer":
return { decision, adopted: true, preserveReason: null };
case "adopt-unverified":
return {
decision,
adopted: true,
preserveReason: "runtime-build-unknown",
};
}
}
+174 -32
View File
@@ -1,96 +1,162 @@
import type {
RuntimeServiceState,
StopAllRuntimesResult,
} from "@one-memory/runtime-control";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { shutdownRuntime } from "./runtime-shutdown";
const { stopRuntimeProcess } = vi.hoisted(() => ({
stopRuntimeProcess: vi.fn<() => Promise<"stopped" | "not-running">>(
async () => "stopped",
),
const { stopAllRuntimeProcesses } = vi.hoisted(() => ({
stopAllRuntimeProcesses: vi.fn<
(options: { dataDir: string }) => Promise<StopAllRuntimesResult>
>(async () => ({ stopped: [4321], unverified: [] })),
}));
vi.mock("@one-memory/runtime-control", () => ({ stopRuntimeProcess }));
vi.mock("@one-memory/runtime-control", () => ({ stopAllRuntimeProcesses }));
const stopMock = vi.mocked(stopRuntimeProcess);
const stopMock = vi.mocked(stopAllRuntimeProcesses);
const enabled: RuntimeServiceState = {
state: "enabled",
reason: "service-enabled",
jobPid: 4321,
};
const disabled = (
reason: RuntimeServiceState["reason"] = "service-enable-marker-absent",
): RuntimeServiceState => ({ state: "disabled", reason, jobPid: null });
const unknown: RuntimeServiceState = {
state: "unknown",
reason: "service-state-timeout",
jobPid: null,
};
beforeEach(() => {
stopMock.mockClear();
stopMock.mockResolvedValue("stopped");
stopMock.mockReset();
stopMock.mockResolvedValue({ stopped: [4321], unverified: [] });
});
describe("BUG-12: a full quit stops the standalone Runtime process", () => {
it("detaches the UI without stopping a managed Runtime service", async () => {
describe("App quit with the background service enabled", () => {
it("never stops the Runtime", async () => {
const stopSupervisor = vi.fn(async () => {});
const events: string[] = [];
const result = await shutdownRuntime({
dataDir: "/tmp/runtime-data",
standalone: true,
preserveManagedRuntime: true,
queryServiceState: async () => enabled,
stopSupervisor,
onDiagnostic: (event) => {
events.push(event.detail);
},
});
expect(result).toEqual({ runtime: "unchanged" });
expect(result).toEqual({
runtime: "preserved",
reason: "background-service-enabled",
});
expect(stopSupervisor).toHaveBeenCalledOnce();
expect(stopMock).not.toHaveBeenCalled();
expect(events).toEqual(["managed-runtime-preserved"]);
expect(events).toEqual(["runtime-preserved:background-service-enabled"]);
});
it("stops the Runtime process in a packaged build", async () => {
const stopSupervisor = vi.fn(async () => {});
it("does not stop a transient Runtime this App started before the service was enabled", async () => {
// Startup fell back to a transient child (service launchctl-disabled) and
// the user re-enabled the service later. Nothing startup saw is an input
// here: quit asks the service manager again and preserves the Runtime.
const queryServiceState = vi.fn(async () => enabled);
const result = await shutdownRuntime({
dataDir: "/tmp/runtime-data",
standalone: true,
stopSupervisor,
queryServiceState,
stopSupervisor: async () => {},
});
expect(queryServiceState).toHaveBeenCalledOnce();
expect(result.runtime).toBe("preserved");
expect(stopMock).not.toHaveBeenCalled();
});
});
describe("App quit with the background service disabled", () => {
it("stops every Runtime of the data dir: the App's child and an external one", async () => {
stopMock.mockResolvedValue({ stopped: [1111, 2222], unverified: [] });
const result = await shutdownRuntime({
dataDir: "/tmp/runtime-data",
standalone: true,
queryServiceState: async () => disabled(),
stopSupervisor: async () => {},
});
expect(result).toEqual({ runtime: "stopped" });
expect(stopMock).toHaveBeenCalledWith({ dataDir: "/tmp/runtime-data" });
expect(stopSupervisor).toHaveBeenCalledOnce();
});
it("does not touch a separate Runtime process in an embedded build", async () => {
const stopSupervisor = vi.fn(async () => {});
it.each([
"service-not-installed",
"service-disabled-by-launchctl",
"service-enable-marker-absent",
] as const)("treats %s as disabled", async (reason) => {
const result = await shutdownRuntime({
dataDir: "/tmp/runtime-data",
standalone: true,
queryServiceState: async () => disabled(reason),
});
expect(result).toEqual({ runtime: "stopped" });
expect(stopMock).toHaveBeenCalledOnce();
});
it("reports a live lease owner it could not verify instead of signalling it", async () => {
stopMock.mockResolvedValue({ stopped: [], unverified: [9999] });
const events: string[] = [];
const result = await shutdownRuntime({
dataDir: "/tmp/runtime-data",
standalone: false,
stopSupervisor,
standalone: true,
queryServiceState: async () => disabled(),
onDiagnostic: (event) => {
events.push(`${event.outcome}:${event.detail}`);
},
});
expect(result).toEqual({ runtime: "unchanged" });
expect(stopMock).not.toHaveBeenCalled();
expect(stopSupervisor).toHaveBeenCalledOnce();
expect(result).toEqual({
runtime: "failed",
detail: "unverified-runtime-left-running",
});
expect(events).toEqual(["degraded:unverified-runtime-left-running"]);
});
it("tears the supervisor down before it stops the process", async () => {
const order: string[] = [];
stopMock.mockImplementation(async () => {
order.push("process");
return "stopped";
return { stopped: [4321], unverified: [] };
});
await shutdownRuntime({
dataDir: "/tmp/runtime-data",
standalone: true,
queryServiceState: async () => {
order.push("query");
return disabled();
},
stopSupervisor: async () => {
order.push("supervisor");
},
});
expect(order).toEqual(["supervisor", "process"]);
expect(order).toEqual(["supervisor", "query", "process"]);
});
it("reports not-running instead of throwing when nothing is attached", async () => {
stopMock.mockResolvedValue("not-running");
it("reports not-running when nothing serves the data dir", async () => {
stopMock.mockResolvedValue({ stopped: [], unverified: [] });
const result = await shutdownRuntime({
dataDir: "/tmp/runtime-data",
standalone: true,
stopSupervisor: async () => {},
queryServiceState: async () => disabled(),
});
expect(result).toEqual({ runtime: "not-running" });
@@ -103,7 +169,7 @@ describe("BUG-12: a full quit stops the standalone Runtime process", () => {
const result = await shutdownRuntime({
dataDir: "/tmp/runtime-data",
standalone: true,
stopSupervisor: async () => {},
queryServiceState: async () => disabled(),
onDiagnostic: async (event) => {
events.push(event.outcome);
},
@@ -117,24 +183,100 @@ describe("BUG-12: a full quit stops the standalone Runtime process", () => {
const result = await shutdownRuntime({
dataDir: "/tmp/runtime-data",
standalone: true,
queryServiceState: async () => disabled(),
stopSupervisor: async () => {
throw new Error("supervisor exploded");
},
});
// The process is what must not be left behind; a supervisor error is not
// an excuse to skip it.
expect(stopMock).toHaveBeenCalledOnce();
expect(result).toEqual({
runtime: "failed",
detail: "supervisor exploded",
});
});
});
describe("App quit when the service state cannot be determined", () => {
it("stops nothing on a timeout and says so", async () => {
const events: string[] = [];
const result = await shutdownRuntime({
dataDir: "/tmp/runtime-data",
standalone: true,
queryServiceState: async () => unknown,
onDiagnostic: (event) => {
events.push(`${event.outcome}:${event.detail}`);
},
});
expect(result).toEqual({
runtime: "preserved",
reason: "service-state-unknown:service-state-timeout",
});
expect(stopMock).not.toHaveBeenCalled();
expect(events).toEqual([
"degraded:runtime-preserved:service-state-unknown:service-state-timeout",
]);
});
it("stops nothing when the query itself throws", async () => {
const result = await shutdownRuntime({
dataDir: "/tmp/runtime-data",
standalone: true,
queryServiceState: async () => {
throw new Error("launchctl exploded");
},
});
expect(result).toEqual({
runtime: "preserved",
reason: "service-state-unknown:service-state-query-failed",
});
expect(stopMock).not.toHaveBeenCalled();
});
});
describe("Runtimes quit never touches", () => {
it("leaves a Runtime whose build could not be read, even with the service disabled", async () => {
const queryServiceState = vi.fn(async () => disabled());
const result = await shutdownRuntime({
dataDir: "/tmp/runtime-data",
standalone: true,
queryServiceState,
preserveReason: "runtime-build-unknown",
});
expect(result).toEqual({
runtime: "preserved",
reason: "runtime-build-unknown",
});
expect(stopMock).not.toHaveBeenCalled();
});
it("does not touch a separate Runtime process in an embedded build", async () => {
const stopSupervisor = vi.fn(async () => {});
const queryServiceState = vi.fn(async () => disabled());
const result = await shutdownRuntime({
dataDir: "/tmp/runtime-data",
standalone: false,
queryServiceState,
stopSupervisor,
});
expect(result).toEqual({ runtime: "unchanged" });
expect(stopMock).not.toHaveBeenCalled();
expect(queryServiceState).not.toHaveBeenCalled();
expect(stopSupervisor).toHaveBeenCalledOnce();
});
it("reports a supervisor failure in an embedded build too", async () => {
const result = await shutdownRuntime({
dataDir: "/tmp/runtime-data",
standalone: false,
queryServiceState: async () => disabled(),
stopSupervisor: async () => {
throw new Error("supervisor exploded");
},
+68 -24
View File
@@ -1,13 +1,24 @@
import { stopRuntimeProcess } from "@one-memory/runtime-control";
import {
type RuntimeServiceState,
type StopAllRuntimesResult,
stopAllRuntimeProcesses,
} from "@one-memory/runtime-control";
export interface RuntimeShutdownOptions {
dataDir: string;
/** Packaged builds run the Runtime as their own process; a dev build's Core
* is this process's child and is stopped by the supervisor. */
standalone: boolean;
/** A managed OS service outlives Electron; a transient App-spawned Runtime
* is still stopped on quit. */
preserveManagedRuntime?: boolean;
/** Asks the service manager whether the background service is on. Called
* at quit time, every time: what startup saw may no longer be true. */
queryServiceState: () => Promise<RuntimeServiceState>;
/** Set when startup adopted a Runtime whose build it could not read. This
* App never stops such a Runtime. A newer Runtime gets no such pass: it
* follows the service state like any adopted Runtime. */
preserveReason?: string | null;
stopRuntimes?: (options: {
dataDir: string;
}) => Promise<StopAllRuntimesResult>;
/** Tear down this process's supervisor state (Core child, broker). */
stopSupervisor?: () => Promise<void>;
onDiagnostic?: (event: {
@@ -19,16 +30,22 @@ export interface RuntimeShutdownOptions {
export type RuntimeShutdownResult =
| { runtime: "stopped" }
| { runtime: "not-running" }
| { runtime: "preserved"; reason: string }
| { runtime: "unchanged" }
| { runtime: "failed"; detail: string };
/** Stop everything a full quit has to stop.
/** Stop everything a full quit has to stop, and nothing else.
*
* Quitting used to tear down this process's own handles and nothing else. In a
* packaged build the Runtime is a separate process, so it outlived the app: the
* window and the tray were gone and the Runtime was still there, and its
* receipt still named a pid, so the next launch concluded it was already
* running and attached to a process that no longer answered.
* Exactly one Runtime serves a data dir. Whether quitting the App ends it is
* decided here, at quit time, from the service manager's answer:
*
* - background service enabled: the Runtime is the user's background memory
* service. Quit never stops it, whoever started the process, including a
* transient Runtime this App started earlier.
* - background service disabled: the Runtime lives only while the App does.
* Quit stops every Runtime of this data dir: the App's own child, one
* `memhub start` launched, a leftover from an earlier session.
* - state unknown (query failed or timed out): stop nothing, and say so.
*
* A failure here is reported, never thrown. By the time this runs the app is
* already quitting, and throwing would only turn a failure someone could see
@@ -39,15 +56,6 @@ export async function shutdownRuntime(
): Promise<RuntimeShutdownResult> {
const stopSupervisor = options.stopSupervisor ?? (async () => {});
const supervisorFailure = await settle(stopSupervisor);
if (options.preserveManagedRuntime) {
await options.onDiagnostic?.({
outcome: supervisorFailure ? "degraded" : "succeeded",
detail: supervisorFailure ?? "managed-runtime-preserved",
});
return supervisorFailure
? { runtime: "failed", detail: supervisorFailure }
: { runtime: "unchanged" };
}
if (!options.standalone) {
if (supervisorFailure) {
await options.onDiagnostic?.({
@@ -58,16 +66,52 @@ export async function shutdownRuntime(
}
return { runtime: "unchanged" };
}
try {
const result = await stopRuntimeProcess({ dataDir: options.dataDir });
const preserved = async (
reason: string,
outcome: "succeeded" | "degraded",
): Promise<RuntimeShutdownResult> => {
await options.onDiagnostic?.({
outcome: supervisorFailure ? "degraded" : "succeeded",
detail: supervisorFailure ?? result,
outcome: supervisorFailure ? "degraded" : outcome,
detail: supervisorFailure ?? `runtime-preserved:${reason}`,
});
return supervisorFailure
? { runtime: "failed", detail: supervisorFailure }
: { runtime: "preserved", reason };
};
if (options.preserveReason) {
return preserved(options.preserveReason, "succeeded");
}
const service = await options.queryServiceState().catch(
(): RuntimeServiceState => ({
state: "unknown",
reason: "service-state-query-failed",
jobPid: null,
}),
);
if (service.state === "enabled") {
return preserved("background-service-enabled", "succeeded");
}
if (service.state !== "disabled") {
// Fail safe: a Runtime stopped by mistake takes the user's background
// memory with it; one left running by mistake is only a process.
return preserved(`service-state-unknown:${service.reason}`, "degraded");
}
try {
const result = await (options.stopRuntimes ?? stopAllRuntimeProcesses)({
dataDir: options.dataDir,
});
const stopped = result.stopped.length > 0 ? "stopped" : "not-running";
const unverified =
result.unverified.length > 0 ? "unverified-runtime-left-running" : null;
await options.onDiagnostic?.({
outcome: supervisorFailure || unverified ? "degraded" : "succeeded",
detail: supervisorFailure ?? unverified ?? `${stopped}:${service.reason}`,
});
if (supervisorFailure) {
return { runtime: "failed", detail: supervisorFailure };
}
return { runtime: result };
if (unverified) return { runtime: "failed", detail: unverified };
return { runtime: stopped };
} catch (error) {
const detail = error instanceof Error ? error.message : "unknown";
await options.onDiagnostic?.({ outcome: "degraded", detail });
@@ -143,6 +143,7 @@ const SAFE_STABLE_RUNTIME_ERROR_CODES = new Set([
"sdk_migration_status_unavailable",
"sdk_migration_incomplete",
"runtime_service_not_ready",
"runtime_build_unknown",
]);
/** Every migration code is a stable token Core or the orchestrator chose;
@@ -242,6 +243,8 @@ export const STABLE_RUNTIME_USER_MESSAGES: Record<string, string> = {
"记忆数据格式升级没有完成,MemHub 暂时无法打开。请重新启动重试;如果仍然失败,请把这个提示反馈给我们,原有记忆数据不会丢失。",
runtime_service_not_ready:
"记忆服务仍在首次启动,请稍候后重试。你的记忆数据不会受到影响。",
runtime_build_unknown:
"无法确认正在运行的 MemHub 后台版本,App 已连接到它,但不会停止或替换它。如遇问题,请更新 App 或重启后台服务;你的记忆数据不会受到影响。",
desktop_start_failed:
"MemHub 启动失败。请重新启动;如果仍然打不开,请重新安装,你的记忆数据不会受到影响。",
};
@@ -0,0 +1,185 @@
// @vitest-environment jsdom
/**
* #230: DeepSeek Harness has no single host, so the app page used to grey the
* switch out with `unsupported_host` and offered no other way to grant recall.
* The one switch now covers every detected profile.
*/
import type { SourceResponse } from "@one-memory/contracts";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { ApplicationDetail } from "./ApplicationDetail";
import { readConnectionPreferences } from "./connection-preferences";
import { resetConnectionReconcile } from "./connection-reconcile";
import { harnessBridge } from "./harness-profiles.test-fixture";
const source = {
source_id: "deepseek-harness",
display_name: "DeepSeek Harness",
authorized: true,
sync_enabled: true,
capabilities: {
historical_import: "implemented",
incremental_import: "implemented",
live_sync: "implemented",
recall: "implemented",
installer: "implemented",
verification: "real_replayed",
},
} as SourceResponse;
function renderDetail() {
return render(
<ApplicationDetail
source={source}
sources={[source]}
stale={false}
setSync={vi.fn()}
refresh={vi.fn().mockResolvedValue({ sources: [source] })}
onBack={vi.fn()}
/>,
);
}
function profileItems() {
return within(
screen.getByRole("list", { name: "DeepSeek Harness 的 Profile" }),
)
.getAllByRole("listitem")
.map((item) => item.textContent);
}
beforeEach(() => {
window.localStorage.clear();
resetConnectionReconcile();
});
afterEach(() => {
cleanup();
window.oneMemory = undefined;
window.localStorage.clear();
resetConnectionReconcile();
});
it("lists every detected profile with its own state", async () => {
harnessBridge({ desktop: "approved", web: "not-installed" });
renderDetail();
await waitFor(() =>
expect(profileItems()).toEqual(["desktop:已接入", "web:未接入"]),
);
const insertion = screen.getByRole("switch", { name: "一键插入应用" });
expect(insertion.hasAttribute("disabled")).toBe(false);
expect(screen.queryByText(/当前安装不支持一键插入/)).toBeNull();
});
it("turns every profile on with the one switch and remembers the choice", async () => {
const { api, state } = harnessBridge({
desktop: "not-installed",
web: "not-installed",
});
renderDetail();
const insertion = await screen.findByRole("switch", { name: "一键插入应用" });
await waitFor(() => expect(profileItems()).toHaveLength(2));
expect(insertion.getAttribute("aria-checked")).toBe("false");
fireEvent.click(insertion);
await waitFor(() =>
expect(profileItems()).toEqual(["desktop:已接入", "web:已接入"]),
);
expect(api.installRecall.mock.calls.map(([hostId]) => hostId)).toEqual([
"deepseek-harness:desktop",
"deepseek-harness:web",
]);
expect([...state.values()]).toEqual(["approved", "approved"]);
expect(insertion.getAttribute("aria-checked")).toBe("true");
// Not "未接通": the self-checks the grants recorded were read back.
await waitFor(() =>
expect(
screen.getByRole("button", { name: /已接通|未接通|检测中/ }).textContent,
).toBe("已接通"),
);
// Remembered, so a profile found on a later launch is connected too.
expect(readConnectionPreferences().hostSourceIds).toEqual([
"deepseek-harness",
]);
});
it("names the profile that failed and keeps the others on", async () => {
const { state } = harnessBridge(
{ desktop: "not-installed", web: "not-installed" },
{ failInstall: ["web"] },
);
renderDetail();
const insertion = await screen.findByRole("switch", { name: "一键插入应用" });
await waitFor(() => expect(profileItems()).toHaveLength(2));
fireEvent.click(insertion);
const alert = await screen.findByRole("alert");
expect(alert.textContent).toContain("以下 Profile 未能接入:web");
expect(alert.textContent).toContain("应用控制通道暂不可用");
await waitFor(() =>
expect(profileItems()).toEqual(["desktop:已接入", "web:未接入"]),
);
expect(state.get("deepseek-harness:desktop")).toBe("approved");
expect(insertion.getAttribute("aria-checked")).toBe("true");
});
it("turns every linked profile off with the one switch", async () => {
const { api, state } = harnessBridge({
desktop: "approved",
web: "approved",
});
renderDetail();
const insertion = await screen.findByRole("switch", { name: "一键插入应用" });
await waitFor(() =>
expect(insertion.getAttribute("aria-checked")).toBe("true"),
);
fireEvent.click(insertion);
await waitFor(() =>
expect(profileItems()).toEqual(["desktop:未接入", "web:未接入"]),
);
expect(api.uninstallRecall).toHaveBeenCalledTimes(2);
expect([...state.values()]).toEqual(["not-installed", "not-installed"]);
expect(insertion.getAttribute("aria-checked")).toBe("false");
expect(readConnectionPreferences().hostSourceIds).toEqual([]);
});
it("names the profile whose connection could not be removed", async () => {
harnessBridge(
{ desktop: "approved", web: "approved" },
{
failUninstall: ["desktop"],
},
);
renderDetail();
const insertion = await screen.findByRole("switch", { name: "一键插入应用" });
await waitFor(() =>
expect(insertion.getAttribute("aria-checked")).toBe("true"),
);
fireEvent.click(insertion);
// Removal failed but revocation went through: the grant is gone, the file
// is left for the person to check.
expect((await screen.findByRole("alert")).textContent).toContain(
"已撤销以下 Profile 的记忆权限,但连接文件没有全部移除,请核对:desktop",
);
});
it("says how to get a profile when none is detected", async () => {
harnessBridge({});
renderDetail();
expect(
await screen.findByText(
"还没有检测到 DeepSeek Harness 的 Profile。创建 Profile 后再回到此页。",
),
).toBeTruthy();
expect(
screen
.getByRole("switch", { name: "一键插入应用" })
.hasAttribute("disabled"),
).toBe(true);
});
+126 -8
View File
@@ -2,15 +2,21 @@ import type { SourceResponse } from "@one-memory/contracts";
import type { MessageKey } from "@one-memory/i18n";
import { useCallback, useEffect, useRef, useState } from "react";
import { AppMark } from "./app-mark";
import { connectionHostId, preferenceHasSource } from "./connection-hosts";
import {
connectionHostId,
isProfileSource,
preferenceHasSource,
profileName,
} from "./connection-hosts";
import {
readConnectionPreferences,
writeConnectionPreference,
} from "./connection-preferences";
import { noteUserHostChoice } from "./connection-reconcile";
import type { RecallLinkCheck } from "./global";
import type { RecallHostStatus, RecallLinkCheck } from "./global";
import {
disconnectHost,
disconnectProfiles,
enableHostConnection,
hostCapabilities,
hostConnectionApproved,
@@ -19,8 +25,11 @@ import {
hostWriteReason,
matchHost,
probeHost,
profileHosts,
profileWriteFailures,
refreshRecallHosts,
setHostMemoryPermission,
sourceConnectionApproved,
UNSUPPORTED_NOTE,
useRecallHosts,
} from "./host-state";
@@ -63,6 +72,24 @@ function connectionLabel(input: {
return "未开启关联";
}
/** The recorded self-check across the profiles that may use memory: wired
* only when every one of them is, otherwise the first one that is not. */
function profilesProbeState(profiles: RecallHostStatus[]) {
const linked = profiles.filter(hostConnectionApproved);
if (!linked.length) return null;
for (const host of linked) {
const state = host.selfCheck?.state ?? null;
if (state !== "wired") return state;
}
return "wired";
}
function profileList(hosts: readonly ProfileHost[]) {
return hosts.map((host) => profileName(host.hostId)).join(", ");
}
type ProfileHost = Pick<RecallHostStatus, "hostId">;
function SourceIcon({ source }: { source: SourceResponse }) {
return (
<AppMark
@@ -215,7 +242,10 @@ export function ApplicationDetail({
if (!mounted.current || version !== epoch.current) return;
setRead({ sourceId: id, state: "ready" });
setCheckedAt(new Date().toISOString());
if (matched?.installed) void probeLink(matched.hostId);
// A per-profile source has no single host to probe; its status comes
// from each profile's recorded self-check instead.
if (matched?.installed && !isProfileSource(id))
void probeLink(matched.hostId);
else setLink(null);
} catch {
if (mounted.current && version === epoch.current)
@@ -292,10 +322,14 @@ export function ApplicationDetail({
}
const current = read.sourceId === source.source_id ? read : null;
const host = matchHost(hosts, source.source_id);
const profileSource = isProfileSource(source.source_id);
const profiles = profileHosts(hosts, source.source_id);
const loading = !current || current.state === "loading";
const readFailed = current?.state === "error";
const approved = hostConnectionApproved(host);
const unsupported = hostConnectionUnsupported(host);
const approved = profileSource
? sourceConnectionApproved(hosts, source.source_id)
: hostConnectionApproved(host);
const unsupported = !profileSource && hostConnectionUnsupported(host);
const insertionChecked = insertionIntent ?? approved;
const memoryChecked = memoryIntent ?? host?.permission?.state === "approved";
const probing =
@@ -305,9 +339,10 @@ export function ApplicationDetail({
loading ||
readFailed ||
(stale && current?.state !== "ready");
const probeState =
link?.probe.state ??
(linkPending || linkFailed ? null : (host?.selfCheck?.state ?? null));
const probeState = profileSource
? profilesProbeState(profiles)
: (link?.probe.state ??
(linkPending || linkFailed ? null : (host?.selfCheck?.state ?? null)));
const stateText = connectionLabel({
approved,
insertionOn: insertionChecked,
@@ -326,10 +361,43 @@ export function ApplicationDetail({
? { reason: failureNote(installError.failure) }
: {}),
});
async function applyProfiles(enabled: boolean) {
if (enabled) {
try {
await enableHostConnection(source.source_id);
} finally {
// Each grant records its self-check on the desktop, and the status row
// reads those; nothing else re-reads them while this page is open.
await refreshRecallHosts().catch(() => {});
}
return;
}
const results = await disconnectProfiles(source.source_id);
const failed = results.filter((item) => item.outcome.state === "failed");
const retained = results.filter(
(item) =>
item.outcome.state === "revoked" || item.outcome.state === "review",
);
if (retained.length)
setInstallError({
key: "appProfilesRevokedRetained",
params: { profiles: profileList(retained) },
});
const first = failed[0]?.outcome;
if (first?.state === "failed")
throw Object.assign(Error("disconnect_failed"), {
profiles: failed.map((item) => profileName(item.hostId)),
failure: {
reason: first.reason,
rereadFailed: first.rereadFailed,
} satisfies HostFailure,
});
}
async function applyInsertion(enabled: boolean) {
const bridge = window.oneMemory;
if (!bridge) return;
setInstallError("");
if (profileSource) return applyProfiles(enabled);
if (enabled) {
await enableHostConnection(source.source_id);
void probeLink(hostId);
@@ -385,6 +453,30 @@ export function ApplicationDetail({
}
// A failed turn-on keeps the stored choice on; a failed turn-off
// shows the grant that is really still there after the re-read.
// Per-profile sources name the profiles that failed; the others
// keep what they got.
const failedProfiles =
profileWriteFailures(error) ??
(error as { profiles?: string[] }).profiles ??
null;
if (failedProfiles) {
setInstallError({
key: wanted
? "appProfilesConnectFailed"
: "appProfilesDisconnectFailed",
params: { profiles: failedProfiles.join(", ") },
failure: wanted
? {
reason: hostWriteReason(error),
rereadFailed: hostRereadFailed(error),
}
: ((error as { failure?: HostFailure }).failure ?? {
reason: null,
rereadFailed: false,
}),
});
continue;
}
setInstallError(
wanted
? {
@@ -561,6 +653,31 @@ export function ApplicationDetail({
<div>
<h2>{text("一键插入应用")}</h2>
<p>{text("安装记忆连接,并默认允许此应用使用已授权记忆。")}</p>
{profileSource &&
(profiles.length ? (
<>
<ul
className="r6-app-profiles"
aria-label={t("appProfilesLabel", {
name: source.display_name,
})}
>
{profiles.map((profile) => (
<li key={profile.hostId}>
{t(
hostConnectionApproved(profile)
? "appProfileOn"
: "appProfileOff",
{ profile: profileName(profile.hostId) },
)}
</li>
))}
</ul>
<p>{t("appProfilesHint")}</p>
</>
) : (
<p>{t("appProfilesNone", { name: source.display_name })}</p>
))}
{unsupported && (
<p>{`${source.display_name} ${text(UNSUPPORTED_NOTE)}`}</p>
)}
@@ -578,6 +695,7 @@ export function ApplicationDetail({
stale ||
appLocationNote !== null ||
unsupported ||
(profileSource && !profiles.length) ||
source.capabilities?.installer !== "implemented" ||
source.capabilities?.recall !== "implemented" ||
!hostCapabilities().install
+23 -18
View File
@@ -31,6 +31,8 @@ import {
hostBridgeAvailable,
hostConnectionApproved,
hostConnectionUnknown,
sourceConnectionApproved,
sourceConnectionIncomplete,
useRecallHosts,
} from "./host-state";
import { subscribePoll } from "./poll-scheduler";
@@ -737,12 +739,7 @@ export function ApplicationsHome({
failure.kind === "host"
? preferences.hostSourceIds.some((id) =>
sameConnectionHost(id, failure.sourceId),
) &&
!hosts.some(
(host) =>
hostConnectionApproved(host) &&
hostMatchesSource(failure.sourceId, host.hostId),
)
) && sourceConnectionIncomplete(hosts, failure.sourceId)
: preferences.syncSourceIds.includes(failure.sourceId) &&
!imports.sources.some(
(source) =>
@@ -754,14 +751,22 @@ export function ApplicationsHome({
const autoFailureText = autoFailures
.map((failure) =>
failure.kind === "host"
? t("apps.autoHostFailed", {
name: nameOf(failure.sourceId),
reason: failure.reason
? t("apps.failureReason", {
reason: localizeFailureReason(failure.reason),
})
: "",
})
? t(
failure.profiles
? "apps.autoHostProfilesFailed"
: "apps.autoHostFailed",
{
name: nameOf(failure.sourceId),
...(failure.profiles
? { profiles: failure.profiles.join(", ") }
: {}),
reason: failure.reason
? t("apps.failureReason", {
reason: localizeFailureReason(failure.reason),
})
: "",
},
)
: t("apps.autoSyncFailed", {
name: nameOf(failure.sourceId),
reason: localizeFailureReason(failure.reason),
@@ -1181,8 +1186,9 @@ export function ApplicationsHome({
t,
callState === "unavailable",
) || sourceSyncActivity(source.source_id, syncHealth, locale, t);
const host = hosts.find((item) =>
sourceMatchesHost(source.source_id, item.hostId),
const recallAllowed = sourceConnectionApproved(
hosts,
source.source_id,
);
return (
<div className="r6-home-app-row" key={source.source_id}>
@@ -1193,8 +1199,7 @@ export function ApplicationsHome({
<p>
{source.sync_enabled
? t("apps.syncEnabled")
: host?.installed &&
host.permission?.state === "approved"
: recallAllowed
? t("apps.recallAllowed")
: source.authorized
? t("apps.historyAllowed")
@@ -13,9 +13,18 @@ vi.mock("axios", () => ({
delete: del,
interceptors: { request: { use: vi.fn() } },
}),
isAxiosError: (value: unknown) =>
typeof value === "object" && value !== null && "isAxiosError" in value,
},
}));
function httpError(status: number, detail: string) {
return Object.assign(new Error(`http_${status}`), {
isAxiosError: true,
response: { status, data: { detail } },
});
}
import {
deleteSdkMemory,
getHomeSummary,
@@ -100,6 +109,18 @@ describe("paged memory library client", () => {
expect(del).toHaveBeenLastCalledWith("/api/v2/memories/c1", undefined);
});
it("treats a delete answered 404 memory_not_found as already gone", async () => {
del.mockRejectedValueOnce(httpError(404, "memory_not_found"));
await expect(deleteSdkMemory("gone")).resolves.toBeUndefined();
const other = httpError(404, "source_not_found");
del.mockRejectedValueOnce(other);
await expect(deleteSdkMemory("x")).rejects.toBe(other);
const failed = httpError(503, "hy_memory_delete_failed");
del.mockRejectedValueOnce(failed);
await expect(deleteSdkMemory("x")).rejects.toBe(failed);
});
it("forwards cancellation for inactive or superseded views", async () => {
const controller = new AbortController();
get.mockResolvedValueOnce({
+19 -3
View File
@@ -763,9 +763,25 @@ export async function deleteSdkMemory(
...(traceId ? { headers: { "X-One-Memory-Trace-Id": traceId } } : {}),
...(params ? { params } : {}),
};
await client.delete(
`/api/v2/memories/${encodeURIComponent(memoryId)}`,
Object.keys(config).length ? config : undefined,
try {
await client.delete(
`/api/v2/memories/${encodeURIComponent(memoryId)}`,
Object.keys(config).length ? config : undefined,
);
} catch (reason) {
// 404 `memory_not_found`: already gone (a second click, or another
// window deleted it first). The memory is not there, which is what the
// person asked for, so it is not an error.
if (isMemoryNotFound(reason)) return;
throw reason;
}
}
function isMemoryNotFound(reason: unknown): boolean {
return (
axios.isAxiosError(reason) &&
reason.response?.status === 404 &&
apiErrorCode(reason) === "memory_not_found"
);
}
@@ -118,6 +118,14 @@
margin: 0;
overflow-wrap: anywhere;
}
.r6-app-profiles {
font-size: 13px;
color: #637786;
line-height: 1.8;
margin: 4px 0;
padding-left: 18px;
overflow-wrap: anywhere;
}
.r6-app-detail-actions {
display: flex;
gap: 12px;
+20 -2
View File
@@ -1,5 +1,6 @@
/** Mirrors desktop `SOURCE_HOST`. OpenClaw and DeepSeek Harness profiles
* are not accepted by one-click insertion. */
/** Mirrors desktop `SOURCE_HOST`. OpenClaw is not accepted by one-click
* insertion; DeepSeek Harness has no single host and goes through
* `PROFILE_SOURCES` instead. */
export const SOURCE_HOST: Readonly<Record<string, string>> = {
cursor: "cursor",
"claude-code": "claude-code",
@@ -14,6 +15,23 @@ export const SOURCE_HOST: Readonly<Record<string, string>> = {
export const ONE_CLICK_SOURCES = new Set(Object.keys(SOURCE_HOST));
/** Sources configured per profile. Each profile is its own host
* (`deepseek-harness:<profile>`, with its own patch, credential and grant), so
* the app's one switch covers every profile the desktop detected. */
export const PROFILE_SOURCES: ReadonlySet<string> = new Set([
"deepseek-harness",
]);
export function isProfileSource(sourceId: string) {
return PROFILE_SOURCES.has(sourceId);
}
/** `deepseek-harness:web` -> `web`. */
export function profileName(hostId: string) {
const colon = hostId.indexOf(":");
return colon < 0 ? hostId : hostId.slice(colon + 1);
}
export function connectionHostId(sourceId: string): string | null {
return SOURCE_HOST[sourceId] ?? null;
}
+38 -5
View File
@@ -1,6 +1,10 @@
import type { SourceResponse } from "@one-memory/contracts";
import { useQuery } from "@tanstack/react-query";
import { connectionHostId, sameConnectionHost } from "./connection-hosts";
import {
connectionHostId,
isProfileSource,
sameConnectionHost,
} from "./connection-hosts";
import {
readConnectionPreferences,
writeConnectionPreference,
@@ -8,11 +12,13 @@ import {
import {
cachedRecallHosts,
enableHostConnection,
hostConnectionApproved,
hostConnectionUnsupported,
hostWriteInFlight,
hostWriteReason,
matchHost,
profileHosts,
profileWriteFailures,
sourceConnectionIncomplete,
} from "./host-state";
import { onClientReset, queryClient } from "./query-client";
import { latestSource, sourceWriteInFlight } from "./source-state";
@@ -37,6 +43,8 @@ export type AutoConnectFailure = {
kind: "sync" | "host";
sourceId: string;
reason: string | null;
/** Which profiles of a per-profile source it was; the others are kept. */
profiles?: string[];
};
export type AutoConnectState = {
running: string[];
@@ -53,8 +61,10 @@ queryClient.setQueryDefaults(AUTO_KEY, {
});
let flight: Promise<AutoConnectFailure[]> | null = null;
const seenProfiles = new Map<string, Set<string>>();
onClientReset(() => {
flight = null;
seenProfiles.clear();
});
function autoState() {
@@ -148,8 +158,26 @@ export async function reconcileConnectionPreferences(
function hostNeedsApply(sourceId: string) {
const hosts = cachedRecallHosts();
if (hosts === undefined) return false;
const host = matchHost(hosts, sourceId);
return !hostConnectionApproved(host) && !hostConnectionUnsupported(host);
return sourceConnectionIncomplete(hosts, sourceId);
}
/** `attempted` holds one apply per source, which is right until a per-profile
* source grows a profile: that one was never tried, so the remembered switch
* gets one more apply. A profile that fails is not retried on every poll. */
function rearmNewProfiles(sourceIds: string[]) {
const hosts = cachedRecallHosts();
if (hosts === undefined) return;
for (const sourceId of sourceIds) {
if (!isProfileSource(sourceId)) continue;
const current = profileHosts(hosts, sourceId).map((host) => host.hostId);
const seen = seenProfiles.get(sourceId);
seenProfiles.set(sourceId, new Set([...(seen ?? []), ...current]));
if (!seen || current.every((hostId) => seen.has(hostId))) continue;
updateAuto((state) => ({
...state,
attempted: state.attempted.filter((key) => key !== hostKey(sourceId)),
}));
}
}
/** Whether a remembered host is still waiting for (or in) its one apply. */
@@ -191,6 +219,7 @@ export function autoReconcile(
if (hostConnectionUnsupported(matchHost(hosts, id)))
writeConnectionPreference("host", id, false);
const preferences = readConnectionPreferences();
rearmNewProfiles(preferences.hostSourceIds);
const { attempted } = autoState();
const only = options.only;
const wantedHosts = only
@@ -256,6 +285,7 @@ export function retryAutoReconcile(options: ReconcileOptions) {
export function resetConnectionReconcile() {
flight = null;
seenProfiles.clear();
queryClient.removeQueries({ queryKey: AUTO_KEY });
}
@@ -301,12 +331,15 @@ async function runReconcile(
// Per-app: other hosts keep going. Unsupported never succeeds on retry.
if (error instanceof Error && error.message === "host_unsupported")
writeConnectionPreference("host", sourceId, false);
else
else {
const profiles = profileWriteFailures(error);
failures.push({
kind: "host",
sourceId,
reason: hostWriteReason(error),
...(profiles ? { profiles } : {}),
});
}
}
}
return failures;
@@ -0,0 +1,120 @@
/** A fake desktop bridge holding DeepSeek Harness profile hosts, for tests. */
import { vi } from "vitest";
import type { RecallHostStatus, RecallPermissionStatus } from "./global";
type Bridge = NonNullable<typeof window.oneMemory>;
type Grant =
| "not-installed"
| "install-only"
| "approved"
| "revoked"
| "unsupported";
function permission(hostId: string, state: Grant): RecallPermissionStatus {
return {
hostId,
state,
reason: null,
receipt:
state === "not-installed" || state === "unsupported"
? null
: {
hostId,
installationId: `install-${hostId}`,
revision: 1,
status: state,
allowedTools: state === "approved" ? ["om_recall"] : [],
allowedScopes: state === "approved" ? ["all-apps"] : [],
},
};
}
/** A desktop that knows a set of Harness profiles and keeps their state. */
export function harnessBridge(
initial: Record<string, Grant>,
options: { failInstall?: string[]; failUninstall?: string[] } = {},
) {
const state = new Map(
Object.entries(initial).map(([profile, grant]) => [
`deepseek-harness:${profile}`,
grant,
]),
);
const hosts = (): RecallHostStatus[] =>
[...state].map(([hostId, grant]) => ({
hostId,
label: `DeepSeek Harness · ${hostId.split(":")[1]}`,
present: true,
installed: grant === "install-only" || grant === "approved",
permission: permission(hostId, grant),
// Recorded by the desktop when the grant lands, as on a real install.
selfCheck:
grant === "approved"
? { state: "wired", detail: "", probed_at: "2026-11-02" }
: null,
skillDrift: null,
}));
const failing = (hostId: string, list: string[] = []) =>
list.some((profile) => hostId === `deepseek-harness:${profile}`);
const api = {
getRecallHosts: vi.fn(async () => hosts()),
getRecallPermission: vi.fn(async (hostId: string) =>
permission(hostId, state.get(hostId) ?? "not-installed"),
),
previewRecallInstall: vi.fn(async (hostId: string) => ({
hostId,
hostLabel: hostId,
files: [],
fingerprint: `fp-${hostId}`,
})),
installRecall: vi.fn(async (hostId: string, _approved?: string) => {
if (failing(hostId, options.failInstall))
throw Error(
"Error invoking remote method 'one-memory:install-recall': Error: host_control_unavailable",
);
state.set(hostId, "install-only");
return {
hostId,
installed: true,
permission: permission(hostId, "install-only"),
};
}),
setRecallPermission: vi.fn(async (hostId: string, _input: unknown) => {
state.set(hostId, "approved");
return permission(hostId, "approved");
}),
revokeRecallPermission: vi.fn(async (hostId: string, _input: unknown) => {
state.set(hostId, "revoked");
return permission(hostId, "revoked");
}),
previewRecallUninstall: vi.fn(async (hostId: string) => ({
hostId,
hostLabel: hostId,
files: [],
review: [],
fingerprint: `fp-${hostId}`,
})),
uninstallRecall: vi.fn(async (hostId: string, _approved?: string) => {
if (failing(hostId, options.failUninstall))
throw Error(
"Error invoking remote method 'one-memory:uninstall-recall': Error: host_control_unavailable",
);
state.set(hostId, "not-installed");
return {
hostId,
installed: false,
permission: permission(hostId, "not-installed"),
};
}),
repairRecallCommands: vi.fn(async () => []),
};
window.oneMemory = api as unknown as Bridge;
return {
api,
state,
addProfile(profile: string) {
state.set(`deepseek-harness:${profile}`, "not-installed");
},
};
}
@@ -0,0 +1,210 @@
// @vitest-environment jsdom
/**
* DeepSeek Harness is configured per profile: every `deepseek-harness:<profile>`
* is its own host with its own patch, credential and grant. The app page has
* one switch for all of them (#230). These pin what that switch does: on
* installs and grants every detected profile, off removes and revokes every
* linked one, a failing profile is named without undoing the others, and a
* profile that appears later is picked up by the remembered switch.
*/
import { afterEach, describe, expect, it } from "vitest";
import { saveConnectionPreferences } from "./connection-preferences";
import {
autoReconcile,
resetConnectionReconcile,
} from "./connection-reconcile";
import { harnessBridge } from "./harness-profiles.test-fixture";
import {
disconnectProfiles,
enableHostConnection,
hostWriteReason,
ProfileWriteError,
profileHosts,
refreshRecallHosts,
sourceConnectionApproved,
sourceConnectionIncomplete,
} from "./host-state";
afterEach(() => {
window.oneMemory = undefined;
window.localStorage.clear();
resetConnectionReconcile();
});
describe("DeepSeek Harness profile hosts", () => {
it("lists only the profiles of the per-profile source", async () => {
harnessBridge({ desktop: "not-installed", web: "approved" });
const hosts = await refreshRecallHosts();
expect(
profileHosts(hosts, "deepseek-harness").map((host) => host.hostId),
).toEqual(["deepseek-harness:desktop", "deepseek-harness:web"]);
expect(profileHosts(hosts, "cursor")).toEqual([]);
// One approved profile reads as on; the other is still to do.
expect(sourceConnectionApproved(hosts, "deepseek-harness")).toBe(true);
expect(sourceConnectionIncomplete(hosts, "deepseek-harness")).toBe(true);
});
});
describe("turning the DeepSeek Harness switch on", () => {
it("installs and grants every profile that is not approved yet", async () => {
const { api, state } = harnessBridge({
desktop: "not-installed",
headless: "revoked",
web: "approved",
});
await refreshRecallHosts();
await enableHostConnection("deepseek-harness");
expect(api.installRecall.mock.calls.map(([hostId]) => hostId)).toEqual([
"deepseek-harness:desktop",
"deepseek-harness:headless",
]);
expect(api.setRecallPermission).toHaveBeenCalledWith(
"deepseek-harness:desktop",
expect.objectContaining({
allowedTools: ["om_recall", "om_conversations", "om_diagnostic_trace"],
allowedScopes: ["all-apps", "source:deepseek-harness"],
userConfirmed: true,
}),
);
expect([...state.values()]).toEqual(["approved", "approved", "approved"]);
});
it("keeps the profiles that worked and names the one that failed", async () => {
const { api, state } = harnessBridge(
{ desktop: "not-installed", web: "not-installed" },
{ failInstall: ["web"] },
);
await refreshRecallHosts();
const error = await enableHostConnection("deepseek-harness").catch(
(reason: unknown) => reason,
);
expect(error).toBeInstanceOf(ProfileWriteError);
expect((error as ProfileWriteError).profiles).toEqual(["web"]);
expect((error as Error).message).toContain("host_control_unavailable");
expect(state.get("deepseek-harness:desktop")).toBe("approved");
expect(state.get("deepseek-harness:web")).toBe("not-installed");
expect(api.setRecallPermission).toHaveBeenCalledTimes(1);
// The failure re-read lands the real state of every profile.
const hosts = await refreshRecallHosts();
expect(sourceConnectionApproved(hosts, "deepseek-harness")).toBe(true);
});
});
describe("turning the DeepSeek Harness switch off", () => {
it("removes and revokes every linked profile and leaves unlinked ones alone", async () => {
const { api, state } = harnessBridge({
desktop: "approved",
headless: "not-installed",
web: "approved",
});
await refreshRecallHosts();
const results = await disconnectProfiles("deepseek-harness");
expect(results.map((item) => [item.hostId, item.outcome.state])).toEqual([
["deepseek-harness:desktop", "removed"],
["deepseek-harness:web", "removed"],
]);
expect(api.uninstallRecall).toHaveBeenCalledTimes(2);
expect(state.get("deepseek-harness:desktop")).toBe("not-installed");
expect(state.get("deepseek-harness:web")).toBe("not-installed");
});
it("revokes a profile whose files cannot be removed and still reaches the rest", async () => {
const { api, state } = harnessBridge(
{ desktop: "approved", web: "approved" },
{ failUninstall: ["desktop"] },
);
await refreshRecallHosts();
const results = await disconnectProfiles("deepseek-harness");
expect(results.map((item) => item.outcome.state)).toEqual([
"revoked",
"removed",
]);
expect(api.revokeRecallPermission).toHaveBeenCalledWith(
"deepseek-harness:desktop",
expect.objectContaining({ userConfirmed: true }),
);
expect(state.get("deepseek-harness:desktop")).toBe("revoked");
expect(state.get("deepseek-harness:web")).toBe("not-installed");
});
});
describe("a profile created after the switch was turned on", () => {
it("is installed and granted by the remembered switch while the app is running", async () => {
saveConnectionPreferences({
syncSourceIds: [],
hostSourceIds: ["deepseek-harness"],
});
const harness = harnessBridge({ desktop: "not-installed" });
await refreshRecallHosts();
expect(await autoReconcile({ sources: [] })).toEqual([]);
expect(harness.api.installRecall).toHaveBeenCalledTimes(1);
// One attempt was already spent on this source; the new profile gets its own.
harness.addProfile("web");
await refreshRecallHosts();
expect(await autoReconcile({ sources: [] })).toEqual([]);
expect(harness.api.installRecall.mock.calls.at(-1)).toEqual([
"deepseek-harness:web",
"fp-deepseek-harness:web",
]);
expect(harness.state.get("deepseek-harness:web")).toBe("approved");
});
it("does not retry a failing profile on every poll", async () => {
saveConnectionPreferences({
syncSourceIds: [],
hostSourceIds: ["deepseek-harness"],
});
const harness = harnessBridge(
{ desktop: "approved", web: "not-installed" },
{ failInstall: ["web"] },
);
await refreshRecallHosts();
expect(await autoReconcile({ sources: [] })).toEqual([
{
kind: "host",
sourceId: "deepseek-harness",
reason: "应用控制通道暂不可用",
profiles: ["web"],
},
]);
await refreshRecallHosts();
expect(await autoReconcile({ sources: [] })).toBeNull();
expect(harness.api.installRecall).toHaveBeenCalledTimes(1);
});
it("stays out while the switch is off", async () => {
const harness = harnessBridge({ desktop: "not-installed" });
await refreshRecallHosts();
expect(await autoReconcile({ sources: [] })).toBeNull();
harness.addProfile("web");
await refreshRecallHosts();
expect(await autoReconcile({ sources: [] })).toBeNull();
expect(harness.api.installRecall).not.toHaveBeenCalled();
});
});
describe("a profile whose name MemHub cannot grant", () => {
it("is named with the real reason and does not hold up the others", async () => {
const { state } = harnessBridge({
My_Profile: "unsupported",
web: "not-installed",
});
await refreshRecallHosts();
const error = await enableHostConnection("deepseek-harness").catch(
(reason: unknown) => reason,
);
expect((error as ProfileWriteError).profiles).toEqual(["My_Profile"]);
expect(hostWriteReason(error)).toBe("appProfileNameUnsupported");
expect(state.get("deepseek-harness:web")).toBe("approved");
// Nothing is left for the remembered switch to keep trying.
expect(
sourceConnectionIncomplete(
await refreshRecallHosts(),
"deepseek-harness",
),
).toBe(false);
});
});
+187 -19
View File
@@ -1,5 +1,10 @@
import { MutationObserver, useQuery } from "@tanstack/react-query";
import { connectionHostId, hostMatchesSource } from "./connection-hosts";
import {
connectionHostId,
hostMatchesSource,
isProfileSource,
profileName,
} from "./connection-hosts";
import type {
RecallHostStatus,
RecallPermissionStatus,
@@ -226,6 +231,7 @@ export function matchHost(
const REASONS: Record<string, string> = {
host_unsupported: "它没有 MCP 通道,无法发放可撤销的记忆凭证",
profile_name_unsupported: "appProfileNameUnsupported",
unsupported_host: "当前安装不支持一键插入",
connection_client_unavailable: "桌面端的连接组件不可用",
noncanonical_data_dir_host_integration_disabled: "测试实例不接入应用",
@@ -350,18 +356,77 @@ export function hostWriteInFlight() {
return queryClient.isMutating({ mutationKey: HOST_WRITE }) > 0;
}
/** A per-profile source writes to every profile, so it waits on each of them. */
function sourceHost(sourceId: string) {
if (isProfileSource(sourceId))
return [
sourceId,
...profileHosts(cachedRecallHosts(), sourceId).map((host) => host.hostId),
];
return [connectionHostId(sourceId) ?? sourceId];
}
/** Every detected profile host of a per-profile source, e.g. DeepSeek Harness. */
export function profileHosts(
hosts: readonly RecallHostStatus[] | undefined,
sourceId: string,
) {
if (!isProfileSource(sourceId)) return [];
return (hosts ?? []).filter((host) => host.hostId.startsWith(`${sourceId}:`));
}
/** The app switch reads as on: its host, or for a per-profile source any
* profile, may use memory. A failed profile is reported, not hidden. */
export function sourceConnectionApproved(
hosts: readonly RecallHostStatus[] | undefined,
sourceId: string,
) {
if (isProfileSource(sourceId))
return profileHosts(hosts, sourceId).some(hostConnectionApproved);
return hostConnectionApproved(matchHost(hosts, sourceId));
}
/** Something the switch would still do: a host, or any profile, that is
* neither approved nor unsupported. */
export function sourceConnectionIncomplete(
hosts: readonly RecallHostStatus[] | undefined,
sourceId: string,
) {
const pending = (host: RecallHostStatus | null) =>
!hostConnectionApproved(host) && !hostConnectionUnsupported(host);
if (isProfileSource(sourceId))
return profileHosts(hosts, sourceId).some(pending);
return pending(matchHost(hosts, sourceId));
}
/** Some profiles of a per-profile source could not be written; the others
* were kept. The message keeps the first cause so `hostWriteReason` names it. */
export class ProfileWriteError extends Error {
readonly profiles: string[];
constructor(profiles: string[], cause: unknown) {
super(
`profile_write_failed ${cause instanceof Error ? cause.message : ""}`.trim(),
);
this.profiles = profiles;
}
}
/** The profile names a failed write left behind, if it was a profile write. */
export function profileWriteFailures(error: unknown): string[] | null {
return error instanceof ProfileWriteError ? error.profiles : null;
}
type Connected = {
hostId: string;
installed: boolean;
permission?: RecallPermissionStatus;
};
async function connect(sourceId: string): Promise<Connected | null> {
const hostId = connectionHostId(sourceId);
type Bridge = NonNullable<ReturnType<typeof bridge>>;
async function connect(sourceId: string): Promise<Connected[]> {
const profiles = isProfileSource(sourceId);
const hostId = profiles ? sourceId : connectionHostId(sourceId);
const api = bridge();
if (
!hostId ||
@@ -371,12 +436,65 @@ async function connect(sourceId: string): Promise<Connected | null> {
!api.setRecallPermission
)
throw Error("unsupported_host");
const existing = matchHost(await refreshRecallHosts(), sourceId);
if (hostConnectionUnsupported(existing)) throw Error("host_unsupported");
if (hostConnectionApproved(existing)) {
await api.repairRecallCommands?.();
return null;
const hosts = await refreshRecallHosts();
if (profiles) return connectProfiles(api, sourceId, hosts);
const result = await grantHost(
api,
hostId,
sourceId,
matchHost(hosts, sourceId),
);
await api.repairRecallCommands?.();
return result ? [result] : [];
}
/** One switch, every detected profile. A profile that fails does not undo
* the ones that worked; the error names it. Profiles already approved are
* left alone, so turning the switch on again only reaches what is missing. */
async function connectProfiles(
api: Bridge,
sourceId: string,
hosts: RecallHostStatus[],
): Promise<Connected[]> {
const profiles = profileHosts(hosts, sourceId);
if (!profiles.length) throw Error("profiles_not_found");
const connected: Connected[] = [];
const failed: string[] = [];
let cause: unknown;
for (const host of profiles) {
try {
const result = await grantHost(api, host.hostId, sourceId, host);
if (result) connected.push(result);
} catch (error) {
failed.push(profileName(host.hostId));
// Harness speaks MCP, so "unsupported" can only mean the id grammar
// refused this name; "no MCP channel" would send the person astray.
cause ??= hostConnectionUnsupported(host)
? Error("profile_name_unsupported")
: error;
}
}
if (failed.length < profiles.length) await api.repairRecallCommands?.();
if (failed.length) throw new ProfileWriteError(failed, cause);
return connected;
}
/** Installs and grants one host; null when it was already approved. */
async function grantHost(
api: Bridge,
hostId: string,
sourceId: string,
existing: RecallHostStatus | null,
): Promise<Connected | null> {
if (
!api.previewRecallInstall ||
!api.installRecall ||
!api.getRecallPermission ||
!api.setRecallPermission
)
throw Error("unsupported_host");
if (hostConnectionUnsupported(existing)) throw Error("host_unsupported");
if (hostConnectionApproved(existing)) return null;
const preview = await api.previewRecallInstall(hostId);
const installed = await api.installRecall(hostId, preview.fingerprint);
let permission =
@@ -394,19 +512,19 @@ async function connect(sourceId: string): Promise<Connected | null> {
}
if (permission.receipt?.status !== "approved")
throw Error("permission_not_approved");
await api.repairRecallCommands?.();
return { hostId, installed: true, permission };
}
function landConnected(result: Connected | null) {
if (result)
function landConnected(results: Connected[]) {
for (const result of results)
landHost(result.hostId, {
installed: result.installed,
permission: result.permission,
});
}
/** Installs and grants one host; resolves only once the grant is approved. */
/** Installs and grants one host (or every profile of a per-profile source);
* resolves only once the grants are approved. */
export const enableHostConnection = hostWrite(
sourceHost,
connect,
@@ -479,6 +597,22 @@ async function disconnect(hostId: string): Promise<Disconnected> {
}
}
function landDisconnected({
hostId,
outcome,
}: {
hostId: string;
outcome: DisconnectOutcome;
}) {
if (outcome.state === "removed")
landHost(hostId, {
installed: false,
...(outcome.permission ? { permission: outcome.permission } : {}),
});
else if (outcome.state !== "failed" && outcome.permission)
landHost(hostId, { permission: outcome.permission });
}
export const disconnectHost = hostWrite(
(hostId: string) => [hostId],
async (
@@ -489,14 +623,48 @@ export const disconnectHost = hostWrite(
const rereadFailed = !(await rereadOnce());
return { hostId, outcome: { ...result, rereadFailed } };
},
({ hostId, outcome }) => {
if (outcome.state === "removed")
landHost(hostId, {
installed: false,
...(outcome.permission ? { permission: outcome.permission } : {}),
landDisconnected,
);
export type ProfileDisconnect = { hostId: string; outcome: DisconnectOutcome };
/** Linked to MemHub at all: installed, or holding a receipt that is not yet
* revoked. A profile that never was has nothing to remove. */
function profileLinked(host: RecallHostStatus) {
const status = host.permission?.receipt?.status;
return host.installed || (status !== undefined && status !== "revoked");
}
/** Turn a per-profile source off: each linked profile is removed and its grant
* revoked, exactly as one host would be. One failure does not stop the rest;
* every outcome is returned so the page can name the profile it concerns. */
export const disconnectProfiles = hostWrite(
sourceHost,
async (sourceId: string): Promise<ProfileDisconnect[]> => {
const profiles = profileHosts(await refreshRecallHosts(), sourceId).filter(
profileLinked,
);
const results: ProfileDisconnect[] = [];
let anyFailed = false;
for (const host of profiles) {
const outcome = await disconnect(host.hostId);
anyFailed ||= outcome.state === "failed";
results.push({
hostId: host.hostId,
outcome:
outcome.state === "failed"
? { ...outcome, rereadFailed: false }
: outcome,
});
else if (outcome.state !== "failed" && outcome.permission)
landHost(hostId, { permission: outcome.permission });
}
if (anyFailed && !(await rereadOnce()))
for (const result of results)
if (result.outcome.state === "failed")
result.outcome = { ...result.outcome, rereadFailed: true };
return results;
},
(results) => {
for (const result of results) landDisconnected(result);
},
);
+8 -1
View File
@@ -91,7 +91,14 @@ describe("single state layer", () => {
});
it("reads and writes host connections only through host-state", () => {
expect(offenders(hostUses, ["host-state.ts", "global.d.ts"])).toEqual([]);
expect(
offenders(hostUses, [
"host-state.ts",
"global.d.ts",
// Test support: it is the fake bridge, and only tests import it.
"harness-profiles.test-fixture.ts",
]),
).toEqual([]);
});
it("reads and writes sources only through source-state", () => {
+1 -1
View File
@@ -282,7 +282,7 @@ Support is declared per immutable GitHub Release manifest:
- Linux x64/arm64 Runtime on native GHA runners.
- macOS arm64/x64 App/Runtime: Developer ID, notarization, native acceptance.
- Windows is not supported.
- `hy-memory==2.0.11` is the current stable SDK dependency; the release
- `hy-memory==2.0.12` is the current stable SDK dependency; the release
lock and [`../MEMORY.md`](../MEMORY.md) are authoritative when the pin changes.
See [`INSTALLATION.md`](INSTALLATION.md).
+13 -3
View File
@@ -69,8 +69,14 @@ node apps/cli/dist/one-memory.cjs sources list
```
- 只有 `hermes` 能带 `--user-id`(非 `local`)或 `--root`;其他来源带了会退 2,什么都不发。
- 用户 ID 不必等于 profile 名:profile 名里有 `:` 等字符时另起一个,例如
`--user-id wife-work --root ~/.hermes/profiles/wife:work`。
- `--root` 里的 `~` 由 CLI 展开为主目录,相对路径按当前目录解析成绝对路径。
- 扫描 `~/.hermes` 时会排除 `profiles/**`,所以 `local` 不会读到 wife 的 `state.db`。
- 扫描时任何层级名为 `profiles` 的目录都会被排除,所以 `local` 不会读到 wife 的 `state.db`,
即使授权的根在 `~/.hermes` 之上。
- `--root` 不能是 `profiles` 目录本身(`hermes_root_is_profiles_container`),也不能与另一个
用户已接入的 Hermes 目录相同、包含它或在它之内(`hermes_root_overlaps_other_user`,解析
symlink 后比较);`~/.hermes` 给 `local`、`~/.hermes/profiles/<name>` 给另一个用户是允许的。
- App 不变:它只读写 `local`,其他来源也都固定是 `local`。
## 3. 看导入进度
@@ -140,7 +146,11 @@ node apps/cli/dist/one-memory.cjs write status <request_id> --user-id wife
- 用户 ID 只能是 1–64 位字母、数字、`.`、`_`、`-`;其他写法退 2,不连 Runtime。
- 不带 `--user-id`(或写 `--user-id local`)时请求里不出现 `user_id`,和以前完全一样。
- 用户之间互相隔离:读、改、删另一个用户的记忆 ID 返回「未找到」;write 的 request id 也只属于提交它的用户。
- 用户之间互相隔离:读、改、删另一个用户的记忆 ID 返回「未找到」(HTTP 404 `memory_not_found`,
与不存在的 ID 完全一样);write 的 request id 也只属于提交它的用户。
- `memories delete <id> --yes` 遇到「未找到」时按已删除处理:提示「未找到记忆 <id>(可能已删除)」,
退出 0;`--json` 为 `state: "committed"` 加 `already_gone: true`。App 里重复点删除也不再报错。
- 读接口(`memories list`、`profile`、`recall` 等背后的 HTTP 响应)都带 `user_id`,即实际读的是哪个用户。
### 画像版本(`profile --versions`)
@@ -177,7 +187,7 @@ node apps/cli/dist/one-memory.cjs settings features reset
```
- 全局设置,不分用户;没设置的功能保持 SDK 默认值,什么都不设时与以前完全一样(App 不变)。
- 可设:`graph_rerank`(图谱重排,仅 ultra)、`same_series_timeline`(需 graph_rerank)、`episode_recall`(片段召回,pro 即可)、`session_episodes`(片段写入)、`episode_summary`(片段摘要,每个片段一次模型调用)、`intention`(默认开)、`few_shot`。
- 可设:`graph_rerank`(图谱重排,仅 ultra)、`same_series_timeline`(需 graph_rerank)、`episode_recall`(片段召回,pro 即可)、`session_episodes`(片段写入)、`episode_summary`(片段摘要,每个片段一次模型调用)、`intention`(默认开)、`intention_dedup`(意图判重,默认开;关闭后检索和写入都不判重、不删除意图)、`few_shot`;以及数值 `intention_dedup_threshold`(意图判重的余弦阈值,默认 0.95,可设 0.8–1.0,例如 `settings features set intention_dedup_threshold=0.9 --yes`)。
- 片段召回只对带片段的记忆有用:同时开 `session_episodes`,之后提取的记忆才按会话归入片段。
- 在当前模式下不生效的功能照样保存,并注明原因(如 `graph_rerank` 在 pro 下「需要 ultra」)。
- 修改会重建 SDK 客户端:正在写的一批按旧设置写完,之后的写入和检索用新设置;不迁移数据,不改集合名。
@@ -5,6 +5,11 @@ Post-cutover hosts use SDK-native Context v2 at
`POST /api/v2/context/search`. A v2 response is never converted into v1 by
inventing revisions, Evidence IDs, paths, or adoption state.
Since MemHub 1.0.6 the v2 response also carries `user_id`, the user whose
memories were searched (`local` unless the request named one). The Host
Bridge validator accepts it as an optional field, so a response from an older
Core without it still validates; any other unknown field still fails closed.
## Transport
- The Broker `/context` route (used by host hooks and `recall-cli --context`;
+73 -17
View File
@@ -32,6 +32,7 @@ prompt and no extra call.
| `session_episodes` | `recall.session_episode_enabled` (`MEMORY_SESSION_EPISODE_ENABLED`) | off | pro or ultra | none |
| `episode_summary` | `recall.episode_summary_enabled` (`MEMORY_EPISODE_SUMMARY_ENABLED`) | off | pro or ultra | LLM: one call per closed episode of 3+ memories |
| `intention` | `llm.enable_intention` (`MEMORY_ENABLE_INTENTION`) | **on** | pro or ultra | none (off shortens the prompt) |
| `intention_dedup` | `recall.intention_dedup_enabled` (`MEMORY_INTENTION_DEDUP_ENABLED`) | **on** | pro or ultra | none |
| `few_shot` | `llm.few_shot_enabled` (`MEMORY_FEW_SHOT_ENABLED`) | off | pro or ultra | tokens (extraction and reconcile) |
- **Graph rerank (usage A).** It propagates support over the graph edges
@@ -52,8 +53,32 @@ prompt and no extra call.
write half of episodes, and both are off by default. Usage B only helps once
episodes are being written. The SDK's session narrative (L3, `finalize_session`)
is a separate feature and is not exposed (section 2).
- **`intention`** is the only feature that is on by default. Turning it off
stops L7 intention memories from being extracted.
- **`intention`** is on by default. Turning it off stops L7 intention
memories from being extracted.
- **`intention_dedup`** (hy-memory 2.0.12) is on by default, which is what
every earlier version did. A search treats intentions of one user and agent
with the same `memory_at` day, the same `valid_until` day and a cosine
similarity at or above `intention_dedup_threshold` as copies: it returns the
earliest and deletes the others from the store. Off, no intention dedup
runs, at read time or within a write batch, and nothing is hidden, dropped
or deleted.
### Parameters
Numeric settings beside the features. Each tunes one feature and acts only
while that feature acts.
| Name | SDK field (env) | Default | Range | Tunes |
|---|---|---|---|---|
| `intention_dedup_threshold` | `recall.intention_dedup_threshold` (`MEMORY_INTENTION_DEDUP_THRESHOLD`, else `MEMORY_DEDUP_THRESHOLD`) | 0.95 | 0.8–1.0 inclusive | `intention_dedup` (read time only) |
Why 0.8–1.0: with dedup on, every intention at or above the threshold is
deleted from the store at read time, and that cannot be undone. Below about
0.8 the embedding models MemHub ships or connects score different plans on
one topic as that close, so a lower value would delete intentions that are
not copies. Cosine similarity cannot exceed 1.0, and 1.0 keeps dedup for
exact copies only. Someone who wants no dedup turns `intention_dedup` off
rather than picking a threshold.
### Left out, and why
@@ -81,11 +106,12 @@ prompt and no extra call.
holds only the features they set:
```json
{"memory_mode": "pro", "memory_features": {"episode_recall": true, "session_episodes": true}}
{"memory_mode": "pro", "memory_features": {"episode_recall": true, "intention_dedup_threshold": 0.9}}
```
The values are strict booleans and unknown keys are refused, so a hand-edited
file with an unknown feature fails to load (`config_invalid`), like any other
Feature values are strict booleans, a parameter is a number in its range, and
unknown keys are refused. So a hand-edited file with an unknown feature or an
out-of-range threshold fails to load (`config_invalid`), like any other
malformed setting.
## 3. How a setting reaches the SDK
@@ -104,7 +130,8 @@ malformed setting.
- Each client's features are fixed when it is built, like its mode. Both the
write handles (`SdkWriteCoordinator`) and the read handle
(`ActiveProfileReadHandle`) are keyed by (profile, revision, mode,
features).
features). The features part of the key is the sorted (name, value) pairs
a person set, parameters included, so a new threshold is a new key.
- A change runs `retire_stale_handles`, the #218 use count. An idle write
handle closes at once, and a write in flight finishes with the features it
started with. The next read builds a client with the new key. The old read
@@ -135,6 +162,22 @@ malformed setting.
"description": "Usage A: propagate support over knowledge-graph edges ..."
}
],
"parameters": [
{
"name": "intention_dedup_threshold",
"value": 0.95,
"configured": null,
"default": 0.95,
"minimum": 0.8,
"maximum": 1.0,
"active": true,
"reason": null,
"requires_feature": "intention_dedup",
"sdk_field": "recall.intention_dedup_threshold",
"env": "MEMORY_INTENTION_DEDUP_THRESHOLD",
"description": "Cosine similarity at or above which ..."
}
],
"non_default": {"graph_rerank": true}
}
```
@@ -143,27 +186,32 @@ malformed setting.
- `configured` is null when unset.
- `default` is the SDK default: its code default, or its own env var when set.
- `active` is false with a `reason` when the feature does nothing right now.
The reason is one of `disabled`, `requires_pro` (lite), `requires_ultra`, or
`requires_graph_rerank` (for `same_series_timeline`).
The reason is one of `disabled`, `requires_pro` (lite), `requires_ultra`,
`requires_graph_rerank` (for `same_series_timeline`), or
`requires_intention_dedup` (for the threshold while `intention_dedup` is off).
- `cost` is one of `none`, `extra_query`, `llm_call`, `prompt_tokens`.
- `features` always lists every feature, in the table's order.
- `parameters` lists every parameter. `value` is what clients are built with;
`minimum` and `maximum` are the range a `PUT` accepts. A parameter is
active exactly when the feature it tunes is.
## 5. `PUT /api/v1/settings/memory-features`
The body has any of the feature names as keys:
- `true` or `false` sets that feature.
- A JSON number sets a parameter, if it is in range (inclusive).
- `null` resets it to the default.
- An absent key is left as it is.
- Anything else answers `422`, including a non-boolean, `"true"`, `1`, or an
unknown name.
- Anything else answers `422`, including a non-boolean feature, `"true"`, `1`,
an unknown name, or a threshold outside 0.8–1.0, a string, or a boolean.
```json
{"graph_rerank": true, "episode_recall": true, "few_shot": null}
{"graph_rerank": true, "intention_dedup_threshold": 0.9, "few_shot": null}
```
The answer is the state of section 4, plus `changed`: the features whose
effective value changed, in the table's order. When the set of configured
The answer is the state of section 4, plus `changed`: the features and then
the parameters whose effective value changed, in the tables' order. When the set of configured
features changes, the SDK clients are rebuilt (section 3). That includes a
reset to a value equal to the default, after which the client carries no
override at all.
@@ -172,10 +220,11 @@ override at all.
`pipeline` in `GET /api/v1/memory-processing` (memory-mode contract §7) gains
`features`, which lists the features whose effective value differs from the
default. It is `{}` while every feature is at its default:
default, and the parameters (as numbers) likewise. It is `{}` while every
setting is at its default:
```json
{"pipeline": {"mode": "pro", "...": "...", "features": {"episode_recall": true, "intention": false}}}
{"pipeline": {"mode": "pro", "...": "...", "features": {"episode_recall": true, "intention_dedup_threshold": 0.9}}}
```
`memhub status --json` and `memhub progress --json` carry it as
@@ -188,14 +237,19 @@ to the memory-mode line.
memhub settings features # list them
memhub settings features set episode_recall=true session_episodes=true # plan only
memhub settings features set episode_recall=true session_episodes=true --yes
memhub settings features set intention_dedup=false --yes # no intention dedup
memhub settings features set intention_dedup_threshold=0.9 --yes # a looser dedup
memhub settings features reset few_shot # back to the default
memhub settings features reset # every set feature
```
`set` prints its plan and needs `--yes`, like `settings mode`, because some
features spend model calls. `reset` only returns features to their defaults,
so it applies at once. Unknown names or values other than `true` / `false` are
a usage error (exit 2) before the Runtime is asked.
so it applies at once. Unknown names, feature values other than `true` /
`false`, and a threshold that is not a plain decimal in 0.8–1.0 are a usage
error (exit 2) before the Runtime is asked. Against an older Runtime that does
not list the threshold, `set` answers `rejected` /
`memory_feature_unsupported` without sending it.
## 8. Rules kept
@@ -216,6 +270,8 @@ a usage error (exit 2) before the Runtime is asked.
| session_episodes | 片段写入:同一会话中提取的记忆归入同一片段。 | Episodes per conversation: memories extracted from one conversation share one episode. | Episodios por conversación: las memorias extraídas de una conversación comparten un episodio. |
| episode_summary | 片段摘要:片段结束时总结一次(每个片段一次模型调用)。 | Episode summaries: each finished episode is summarized once (one model call per episode). | Resúmenes de episodio: cada episodio terminado se resume una vez (una llamada al modelo por episodio). |
| intention | 意图提取:随事实一起记住你提到的计划和意图。 | Intentions: remembers the plans and intentions you mention, along with facts. | Intenciones: recuerda los planes e intenciones que mencionas, junto con los hechos. |
| intention_dedup | 意图去重:同一天里几乎相同的意图只保留最早一条,其余删除。 | Intention dedup: of near-identical intentions from one day, keeps the earliest and deletes the rest. | Deduplicación de intenciones: de las intenciones casi idénticas de un mismo día, conserva la más antigua y borra las demás. |
| intention_dedup_threshold | 去重阈值:越高,越少的意图被视为重复。 | Dedup threshold: the higher it is, the fewer intentions count as copies. | Umbral de deduplicación: cuanto más alto, menos intenciones cuentan como copias. |
| few_shot | 提取示例:在提取提示中加入示例,可帮助较弱的模型(输入更长)。 | Extraction examples: adds examples to the extraction prompt, which can help a weaker model (longer input). | Ejemplos de extracción: añade ejemplos a la instrucción de extracción, lo que puede ayudar a un modelo más débil (entrada más larga). |
| default | 默认 | Default | Predeterminado |
| requires_ultra | 当前模式下不生效,需要 ultra。 | Has no effect in this mode; needs ultra. | No tiene efecto en este modo; requiere ultra. |
@@ -20,6 +20,20 @@ cursor。旧文件后续追加也必须能被再次发现,不能只有一个
用户授权是读取前置条件。环境变量只用于发现候选根,不能代替授权。每个候选
文件必须在打开时再次验证 realpath 归属、symlink、普通文件类型和大小预算。
多用户(仅 Hermes):一个授权根属于一个 `user_id`。Hermes 的扫描、检测和
持续同步共用同一次遍历,**任何层级**名为 `profiles` 的目录都不进入(发现
helper 的 `excluded_directory_names`),所以授权根即使在 `~/.hermes` 之上,
也不会读到 `.hermes/profiles/<name>`。接入时 Core 拒绝:
- 根的最后一段是 `profiles`(profiles 容器本身):422
`hermes_root_is_profiles_container`;
- 根(解析 symlink 后)与另一个用户未撤销的 Hermes 授权根相同、包含它或在
它之内:409 `hermes_root_overlaps_other_user`。唯一例外是一方正好是另一方
的 `profiles/<name>`(`local` 在 `~/.hermes`、另一个用户在
`~/.hermes/profiles/<name>`),因为上面的排除已把两者隔开。
本机 `local` 的接入(`/sources/connect`、`/sources/{id}/authorize`)也走同一检查。
## Imported source record
Adapter 输出的 `ScannedEvidence` 是兼容命名的导入中间表示。原文持久化与 raw
+1 -1
View File
@@ -40,7 +40,7 @@ Preview 不会弹出给用户;开关点击本身被视为确认。关闭时通
| [Qwen Code](qwen-code.md) | 开放 | MCP + Skill | 无自动 Hook | `$QWEN_HOME/projects` 扫描 |
| [Pi](pi.md) | 开放 | CLI Skill,不使用 MCP | JS Capture Extension | Pi session 扫描 + Extension 实时入口 |
| [OpenClaw](openclaw.md) | 正式产品冻结 | 当前没有;仅有未开放的底层 MCP 写入器 | 没有 Hook、Plugin 或 Prefetch | Adapter 也被冻结,开关不可用 |
| [DeepSeek Harness](deepseek-harness.md) | 详情页排除,来源管理页按 Profile 接入 | Profile 级 MCP Patch | 无自动 Hook | `$DSH_HOME/sessions` 扫描 |
| [DeepSeek Harness](deepseek-harness.md) | 开放;一个开关覆盖全部已检测 Profile | Profile 级 MCP Patch | 无自动 Hook | `$DSH_HOME/sessions` 扫描 |
## 同步开关的统一精确语义
+37 -26
View File
@@ -1,28 +1,45 @@
# DeepSeek Harness 一键插入支持
更新日期:2026-10-18
更新日期:2026-11-02
## 结论
DeepSeek Harness 的 Profile 级 MCP 安装器已经实现,Profile Scoped Host ID(`deepseek-harness:<profile>`)在权限层按不透明标签处理,可正常走完 Preview 与安装。
DeepSeek Harness 按 Profile 配置 MCP:每个 Profile 是一个独立 Host(`deepseek-harness:<profile>`),有自己的 `cordis.patch.yml`、Credential 与 Grant。应用详情页只有一个“一键插入应用”开关,它一次覆盖桌面检测到的全部 Profile:打开时逐个安装并授权,关闭时逐个移除并撤销。
## 接入方式和产品状态
| 项目 | DeepSeek Harness 当前实现 |
|---|---|
| 应用详情页一键插入 | 有意排除,因为无法选择 Profile |
| 来源管理页 Profile 安装 | 可用;每个 Profile 独立 Patch、Credential 与 Grant |
| 应用详情页一键插入 | 可用;一个开关覆盖全部已检测 Profile,并列出每个 Profile 的状态 |
| 首次导入批量连接 | 排除;在应用详情页打开开关 |
| Harness 查询 MemHub | 通过 Profile 级 Cordis MCP Client |
| Skill | 没有 |
| Prompt 前自动 Prefetch | 没有 |
| Capture Hook/Plugin | 没有 |
| Harness 新会话进入 MemHub | Core 扫描 `$DSH_HOME/sessions` |
| Harness 新会话进入 MemHub | Core 扫描 `$DSH_HOME/sessions`,按 `session[.vN].jsonl[.zstd]` 识别会话文件 |
会话文件按写入时的格式版本命名:首版为 `session.jsonl` / `session.jsonl.zstd`,v3/v4 起为 `session.v3.jsonl.zstd` / `session.v4.jsonl.zstd`。来源扫描接受带版本号的名称;解析前再读文件头 `version` 字段,只导入已验证的 v0/v3/v4,未来未知版本不会被解析:整个文件以 `unsupported_schema` 报告,且不留下读取进度,将来支持该版本的发布会从头读取(错误导入比暂时漏导更难回滚)。`tool/result` 的输出无论写成 `tool-result` 块还是纯 `text` 块,都按同一条裁剪与凭据清洗路径导入。
## 两个开关分别做什么
### “一键插入应用”
### 打开“一键插入应用”
应用详情页不可用。来源管理页对每个 Profile 写 `cordis.patch.yml`、创建独立 Credential 并授权;Host ID 形如 `deepseek-harness:<profile>`,冒号只作为标签存在,权限层的安装目录按 Host ID 摘要生成,不参与路径拼接。
应用详情页对每个已检测、尚未获批的 Profile:
1. 预览并写入该 Profile 的 `cordis.patch.yml` 受管块,创建独立 Credential;
2. 授予 `om_recall`、`om_conversations`、`om_diagnostic_trace`,范围为 `all-apps` 与 `source:deepseek-harness`(与其他应用的一键插入相同)。
已获批的 Profile 不会重装。某个 Profile 失败时,其他 Profile 的结果保留,页面提示失败的 Profile 名称;开关只要有一个 Profile 获批就显示为开。Host ID 里的冒号只作为标签存在,权限层的安装目录按 Host ID 摘要生成,不参与路径拼接。
### 关闭“一键插入应用”
对每个已接入的 Profile 先撤销 Credential,再删除受管块(与其他应用的关闭语义相同)。受管块被改过、无法删除时仍会撤销授权,页面列出需要核对的 Profile;撤销也失败的 Profile 会单独列出,它仍可使用记忆。从未接入的 Profile 不受影响。
### 之后新建的 Profile
桌面每次刷新应用接入状态时都会重新枚举 Profile(`$DSH_HOME/profiles/<profile>/cordis.yml`),应用首页打开时每 3 秒刷新一次;常驻的 Runtime 收到未知的 Profile Host ID 时也会重新读盘。开关的选择会被记住:开关为开时,新出现的 Profile 由应用首页自动安装并授权,不需要重启 MemHub。
每个新 Profile 触发一次自动尝试;失败时应用首页会指明 Profile,不会每次轮询都重试,下次启动或点击重试时再试一次。开关为关时不会自动接入任何 Profile。
### 打开“同步开关”
@@ -32,12 +49,10 @@ DeepSeek Harness 的 Profile 级 MCP 安装器已经实现,Profile Scoped Host
Core 停止扫描和 pending Job claim,running Job可结束,已有 Memory 保留。它不会修改任何 Profile 的 `cordis.patch.yml` 或 Credential。
### 修复一键安装后的组合语义
### 组合语义
未来修复 Profile Host ID 后:
- 某个 Profile 的一键插入只决定该 Profile 是否能查询 MemHub;
- 全局同步开关决定 MemHub 是否继续读取整个 DeepSeek Harness 的 Session;
- 一键插入决定已检测 Profile 是否能查询 MemHub;
- 同步开关决定 MemHub 是否继续读取整个 DeepSeek Harness 的 Session;
- 没有自动 Prefetch,也没有 Capture Hook。
## Profile 粒度
@@ -58,7 +73,7 @@ deepseek-harness:headless
...
```
理论上每个 Profile 拥有独立 Patch、Journal、Credential 和 Grant;Core 会话同步仍按统一的 `deepseek-harness` 来源扫描 `$DSH_HOME/sessions`。
每个 Profile 拥有独立 Patch、Journal、Credential 和 Grant;Core 会话同步仍按统一的 `deepseek-harness` 来源扫描 `$DSH_HOME/sessions`。
## MCP Patch
@@ -85,13 +100,15 @@ DeepSeek Harness 不安装 Skill,也不安装 Capture Hook;新会话进入 M
## UI 状态
- 应用详情页排除 DeepSeek Harness,因为单一来源页无法选择 Profile。
- 首次导入批量连接也排除它。
- 来源管理页会列出每个 Profile,并显示独立安装按钮。
- 应用详情页的“一键插入应用”下方列出每个已检测 Profile 及其“已接入 / 未接入”状态;没有检测到 Profile 时开关不可用,并提示创建 Profile 后回到此页。
- Host ID 只接受小写字母、数字和连字符(最长 32 个字符)。名称不符合的 Profile 仍会列出,但无法授权,页面会指明它和原因,不影响其他 Profile。
- 连接状态取各已接入 Profile 记录的自检结果:全部接通才显示“已接通”。
- 首次导入批量连接排除 DeepSeek Harness。
- CLI 的 `memhub host install deepseek-harness:<profile>` 只安装,不授权(状态停在 `install-only`);授权入口只有应用详情页的开关,CLI 没有授权命令。
## 已修复:Profile Host ID 校验
此前来源管理页安装会在写入 `cordis.patch.yml` 和创建 Credential 之前失败,原因是:
此前 Profile 安装会在写入 `cordis.patch.yml` 和创建 Credential 之前失败,原因是:
```text
系统生成 hostId = deepseek-harness:<profile>
@@ -101,18 +118,12 @@ HostGrantStore 也不接受冒号
Host ID 是不可解释的标签:安装目录对它取摘要,不会把它拼进路径。现在权限层与 Grant 存储都接受一个可选的 `:<profile>` 段,因此 Profile 安装可以正常走完。这不是 DeepSeek Harness 不支持 MCP 的问题,而是 Profile Host ID 格式与权限校验不一致,现已对齐。
## 修复后的权限语义风险
## 已修复:应用详情页返回 `unsupported_host`
来源管理页当前默认授权选择可能包含:
- `om_recall`、`om_conversations`、`om_diagnostic_trace`(`om_ask` 已随 `MemHub_ask` 移除,旧授权仍可读取但不再授权任何工具);
- `all-apps`;
- 所有已授权 Source 和 Workspace。
这比应用详情页通用授权更宽。Host ID 修复后仍应确认是否希望每个 Profile 默认获得如此宽的权限。
此前应用详情页按“一个来源对应一个 Host”接入,而 DeepSeek Harness 没有不带 Profile 的 Host,开关因此置灰并返回 `unsupported_host`,安装后的 Profile 也只能停在 `install-only`(#230)。现在开关直接作用于全部 `deepseek-harness:<profile>` Host,复用与其他应用相同的安装、授权、撤销与卸载接口。
## 重复安装与卸载
底层 Patch 安装是幂等的:已有受管块时替换,不追加第二份。相同 Row ID 出现在 MemHub 哨兵块外时默认拒绝接管。
正常卸载设计是先撤销该 Profile Credential,再删除受管块并保留其他 Patch;如果删完没有其他 Row,恢复 `[]`。用户修改过受管块时不强删。
卸载先撤销该 Profile Credential,再删除受管块并保留其他 Patch;如果删完没有其他 Row,恢复 `[]`。用户修改过受管块时不强删。
+3
View File
@@ -118,6 +118,9 @@ export interface SdkContextSearchResponse {
used_characters: number;
truncated: boolean;
total_ms: number;
/** Whose memories were searched; `local` unless the request named a user.
* Absent from a Core older than 1.0.6. */
user_id?: string;
}
/** Native Host Bridge wire response. Schemas stay distinct and are never adapted. */
+16 -1
View File
@@ -425,6 +425,8 @@ export interface HomeSummary {
export interface SdkHomeSummary {
schema_version: 2;
/** The user the card was read for (`local` unless one was named). */
user_id?: string;
memory_count: number;
latest: SdkMemoryRecord | null;
observed_at: string;
@@ -605,6 +607,8 @@ export interface SdkMemoryRecord {
}
export interface SdkMemoryPage {
/** `GET /api/v2/memory-library`: the user the page was read for. */
user_id?: string;
memories: SdkMemoryRecord[];
total: number;
limit: number;
@@ -621,6 +625,8 @@ export interface SdkMemoryPage {
}
export interface SdkUserProfileResponse {
/** The user whose profile this is (`local` unless one was named). */
user_id?: string;
request_id: string;
profile: Record<string, unknown> | null;
elapsed_ms: number;
@@ -649,6 +655,8 @@ export interface SdkRawRecord {
}
export interface SdkRawPage {
/** The user whose raw turns these are. */
user_id?: string;
items: SdkRawRecord[];
total: number;
limit: number;
@@ -659,6 +667,8 @@ export interface SdkRawPage {
/** `GET /api/v2/memory-library/layers`: memories per SDK layer. */
export interface SdkLayerCounts {
/** The user whose memories were counted. */
user_id?: string;
layers: Record<string, number>;
total: number;
statuses: string[] | null;
@@ -763,7 +773,8 @@ export type AskMemoryEvent =
| { type: "error"; code: string };
export type SdkAskMemoryEvent =
| { type: "recall"; memories: SdkAskMemory[] }
/** `user_id`: whose memories answer (Core 1.0.6+). */
| { type: "recall"; memories: SdkAskMemory[]; user_id?: string }
| { type: "delta"; text: string }
| {
type: "final";
@@ -833,6 +844,8 @@ export interface RecallReceipt {
fallback_reason?: "model_unavailable" | "no_memories" | null;
/** Sources the caller was not allowed to read, left out of this result. */
excluded_sources?: string[];
/** Whose memories were searched (`local` unless the request named a user). */
user_id?: string;
}
export interface LiveSyncHealth {
@@ -874,6 +887,8 @@ export interface MemoryGraphEdge {
}
export interface MemoryGraphResponse {
/** The user whose memories were mapped. */
user_id?: string;
total_memories: number;
projected_memories: number;
nodes: MemoryGraphNode[];
@@ -90,6 +90,11 @@ it("accepts only the SDK context shape", () => {
).toBe(false);
expect(contextResponseInvalidField(mixed)).toBeNull();
expect(contextResponseInvalidField(legacyShape)).toBe("response");
// Core 1.0.6 echoes whose memories were searched; an older Core does not.
expect(isSdkContextSearchResponse({ ...mixed, user_id: "local" })).toBe(true);
expect(isSdkContextSearchResponse({ ...mixed, user_id: "" })).toBe(false);
expect(isSdkContextSearchResponse({ ...mixed, user_id: 7 })).toBe(false);
expect(isSdkContextSearchResponse({ ...mixed, owner: "local" })).toBe(false);
});
async function fixture(
@@ -140,15 +140,22 @@ export function isSdkContextSearchResponse(
): value is SdkContextSearchResponse {
if (
!record(value) ||
!exact(value, [
"schema_version",
"request_id",
"state",
"items",
"used_characters",
"truncated",
"total_ms",
]) ||
// `user_id` (whose memories were searched) is optional so a Core from
// before it was echoed still validates.
!exact(
value,
[
"schema_version",
"request_id",
"state",
"items",
"used_characters",
"truncated",
"total_ms",
],
["user_id"],
) ||
(value.user_id !== undefined && !text(value.user_id, 1, 64)) ||
value.schema_version !== 2 ||
!text(value.request_id, 1, 64) ||
!member(value.state, ["grounded", "empty", "partial", "unavailable"]) ||
@@ -223,8 +230,15 @@ function record(value: unknown): value is Record<string, unknown> {
function only(value: Record<string, unknown>, keys: string[]): boolean {
return Object.keys(value).every((key) => keys.includes(key));
}
function exact(value: Record<string, unknown>, keys: string[]): boolean {
return only(value, keys) && keys.every((key) => Object.hasOwn(value, key));
function exact(
value: Record<string, unknown>,
keys: string[],
optional: string[] = [],
): boolean {
return (
only(value, [...keys, ...optional]) &&
keys.every((key) => Object.hasOwn(value, key))
);
}
function text(value: unknown, min: number, max: number): value is string {
return (
@@ -545,6 +545,7 @@ const baseEnglishMessages = {
nativeDevelopmentUnavailable: " (unavailable in development)",
nativeOpen: "Open {product}",
nativeQuit: "Quit {product} (background service keeps running)",
nativeQuitStopsService: "Quit {product} (also stops the memory service)",
nativeLoginError: "Could not change the login startup setting. Please retry.",
nativeMoveTitle: "Move MemHub to Applications first",
nativeMoveDetail:
@@ -926,6 +927,23 @@ const baseEnglishMessages = {
"Memory access was revoked, so {name} can no longer look up memories. Full removal could not be confirmed; review these paths manually: {paths}.",
appRemovalReview:
"Full connection removal could not be confirmed. Review these paths manually: {paths}.",
appProfilesLabel: "{name} profiles",
appProfileOn: "{profile}: connected",
appProfileOff: "{profile}: not connected",
appProfilesHint:
"The switch installs the memory connection and grants access for every detected profile, and revokes them all when turned off. While it is on, new profiles are connected automatically.",
appProfilesNone:
"No {name} profile was detected. Create a profile, then come back to this page.",
appProfilesConnectFailed:
"These profiles could not be connected: {profiles}. The other profiles were kept. {reason}",
appProfilesDisconnectFailed:
"These profiles could not be disconnected and can still use memories: {profiles}. Please retry. {reason}",
appProfilesRevokedRetained:
"Memory access was revoked for these profiles, but not all of their connection files were removed; review them: {profiles}.",
appProfileNameUnsupported:
"profile names may only use lowercase letters, digits and hyphens, up to 32 characters",
"apps.autoHostProfilesFailed":
"{name} could not connect these profiles automatically ({profiles}){reason}.",
"Cursor 的连接凭证已更新。请完全退出并重新打开 Cursor,再开始新对话。":
"Cursor's connection credential changed. Fully quit and reopen Cursor before starting a new conversation.",
连接其他工具: "Connect other tools",
@@ -13,6 +13,7 @@ export const recoveryChineseMessages = {
nativeDevelopmentUnavailable: "(开发模式不可用)",
nativeOpen: "打开 {product}",
nativeQuit: "退出 {product}(后台服务继续运行)",
nativeQuitStopsService: "退出 {product}(同时停止记忆服务)",
nativeLoginError: "无法更改登录启动设置,请重试。",
nativeMoveTitle: "请先将 MemHub 拖到“应用程序”文件夹",
nativeMoveDetail:
@@ -115,6 +116,8 @@ export const recoverySpanishMessages: Record<
nativeDevelopmentUnavailable: " (no disponible en desarrollo)",
nativeOpen: "Abrir {product}",
nativeQuit: "Salir de {product} (el servicio sigue en segundo plano)",
nativeQuitStopsService:
"Salir de {product} (también detiene el servicio de memoria)",
nativeLoginError:
"No se pudo cambiar el inicio de sesión. Inténtalo de nuevo.",
nativeMoveTitle: "Arrastra MemHub a la carpeta Aplicaciones primero",
@@ -298,6 +298,23 @@ export const settingsSpanishMessages = {
"Se revocó el permiso; {name} ya no puede consultar memorias. No se pudo confirmar la eliminación completa de la conexión. Comprueba manualmente: {paths}.",
appRemovalReview:
"No se pudo confirmar la eliminación completa de la conexión. Comprueba manualmente: {paths}.",
appProfilesLabel: "Perfiles de {name}",
appProfileOn: "{profile}: conectado",
appProfileOff: "{profile}: sin conectar",
appProfilesHint:
"El interruptor instala la conexión de memorias y concede acceso a cada perfil detectado, y los revoca todos al apagarlo. Mientras esté activado, los perfiles nuevos se conectan automáticamente.",
appProfilesNone:
"No se detectó ningún perfil de {name}. Crea un perfil y vuelve a esta página.",
appProfilesConnectFailed:
"No se pudieron conectar estos perfiles: {profiles}. Los demás perfiles se mantienen.{reason}",
appProfilesDisconnectFailed:
"No se pudieron desconectar estos perfiles y aún pueden usar memorias: {profiles}. Reintenta.{reason}",
appProfilesRevokedRetained:
"Se revocó el acceso a memorias de estos perfiles, pero no se eliminaron todos sus archivos de conexión; revísalos: {profiles}.",
appProfileNameUnsupported:
"los nombres de perfil solo admiten minúsculas, dígitos y guiones, hasta 32 caracteres",
"apps.autoHostProfilesFailed":
"No se pudieron conectar automáticamente estos perfiles de {name} ({profiles}){reason}. ",
"Cursor 的连接凭证已更新。请完全退出并重新打开 Cursor,再开始新对话。":
"Las credenciales de conexión de Cursor se actualizaron. Cierra completamente Cursor y vuelve a abrirlo; después, inicia una conversación nueva.",
连接其他工具: "Conectar otras herramientas",
@@ -350,6 +367,23 @@ const chineseOverrides: Partial<
appRevokedReview:
"已撤销记忆权限,{name} 已查不到记忆。但无法确认连接已全部移除,需要你手动核对:{paths}。",
appRemovalReview: "无法确认连接已全部移除,需要你手动核对:{paths}。",
appProfilesLabel: "{name} 的 Profile",
appProfileOn: "{profile}:已接入",
appProfileOff: "{profile}:未接入",
appProfilesHint:
"开关会为每个已检测到的 Profile 安装记忆连接并授权,关闭时全部撤销。开关开启期间,新建的 Profile 会自动接入。",
appProfilesNone:
"还没有检测到 {name} 的 Profile。创建 Profile 后再回到此页。",
appProfilesConnectFailed:
"以下 Profile 未能接入:{profiles}。其他 Profile 已保留。{reason}",
appProfilesDisconnectFailed:
"以下 Profile 的连接未能移除,记忆权限也未能撤销,仍可使用记忆:{profiles}。请重试。{reason}",
appProfilesRevokedRetained:
"已撤销以下 Profile 的记忆权限,但连接文件没有全部移除,请核对:{profiles}。",
appProfileNameUnsupported:
"Profile 名称只能包含小写字母、数字和连字符,最长 32 个字符",
"apps.autoHostProfilesFailed":
"{name} 的以下 Profile 未能自动接入({profiles}){reason}。",
};
export const settingsChineseMessages = Object.fromEntries(
Object.keys(settingsSpanishMessages).map((key) => [
@@ -713,6 +713,8 @@ export const germanMessages: Record<MessageKey, string> = {
nativeDevelopmentUnavailable: " (in Entwicklung nicht verfügbar)",
nativeOpen: "{product} öffnen",
nativeQuit: "{product} beenden (Hintergrunddienst läuft weiter)",
nativeQuitStopsService:
"{product} beenden (Gedächtnisdienst wird ebenfalls beendet)",
nativeLoginError:
"Starteinstellung bei Anmeldung konnte nicht geändert werden. Bitte wiederholen Sie den Vorgang.",
nativeMoveTitle: "Verschieben Sie MemHub zuerst nach „Programme“",
@@ -1103,6 +1105,23 @@ export const germanMessages: Record<MessageKey, string> = {
"Der Erinnerungszugriff wurde widerrufen; {name} kann keine Erinnerungen mehr abfragen. Die vollständige Entfernung konnte nicht bestätigt werden; prüfen Sie diese Pfade manuell: {paths}.",
appRemovalReview:
"Vollständige Entfernung der Verbindung konnte nicht bestätigt werden. Prüfen Sie diese Pfade manuell: {paths}.",
appProfilesLabel: "Profile von {name}",
appProfileOn: "{profile}: verbunden",
appProfileOff: "{profile}: nicht verbunden",
appProfilesHint:
"Der Schalter installiert für jedes erkannte Profil die Erinnerungsverbindung und erteilt den Zugriff; beim Ausschalten wird er für alle widerrufen. Solange er eingeschaltet ist, werden neue Profile automatisch verbunden.",
appProfilesNone:
"Es wurde kein Profil von {name} erkannt. Legen Sie ein Profil an und kehren Sie dann zu dieser Seite zurück.",
appProfilesConnectFailed:
"Diese Profile konnten nicht verbunden werden: {profiles}. Die übrigen Profile bleiben erhalten. {reason}",
appProfilesDisconnectFailed:
"Diese Profile konnten nicht getrennt werden und können weiterhin Erinnerungen nutzen: {profiles}. Bitte versuchen Sie es erneut. {reason}",
appProfilesRevokedRetained:
"Der Erinnerungszugriff dieser Profile wurde widerrufen, aber nicht alle Verbindungsdateien wurden entfernt; prüfen Sie sie: {profiles}.",
appProfileNameUnsupported:
"Profilnamen dürfen nur Kleinbuchstaben, Ziffern und Bindestriche enthalten, höchstens 32 Zeichen",
"apps.autoHostProfilesFailed":
"Diese Profile von {name} konnten nicht automatisch verbunden werden ({profiles}){reason}.",
"Cursor 的连接凭证已更新。请完全退出并重新打开 Cursor,再开始新对话。":
"Die Verbindungs-Anmeldedaten von Cursor haben sich geändert. Beenden Sie Cursor vollständig und öffnen Sie es erneut, bevor Sie eine neue Unterhaltung beginnen.",
连接其他工具: "Andere Tools verbinden",
@@ -723,6 +723,8 @@ export const frenchMessages: Record<MessageKey, string> = {
nativeOpen: "Ouvrir {product}",
nativeQuit:
"Quitter {product} (le service en arrière-plan continue de fonctionner)",
nativeQuitStopsService:
"Quitter {product} (le service de mémoire s’arrête aussi)",
nativeLoginError:
"Impossible de modifier le réglage de lancement à l’ouverture de session. Veuillez réessayer.",
nativeMoveTitle: "Déplacez d’abord MemHub dans Applications",
@@ -1116,6 +1118,23 @@ export const frenchMessages: Record<MessageKey, string> = {
"L’accès aux mémoires a été révoqué : {name} ne peut plus rechercher de mémoires. La suppression complète n’a pas pu être confirmée ; vérifiez manuellement ces chemins : {paths}.",
appRemovalReview:
"La suppression complète de la connexion n’a pas pu être confirmée. Vérifiez manuellement ces chemins : {paths}.",
appProfilesLabel: "Profils de {name}",
appProfileOn: "{profile} : connecté",
appProfileOff: "{profile} : non connecté",
appProfilesHint:
"L’interrupteur installe la connexion aux mémoires et accorde l’accès à chaque profil détecté, puis les révoque tous lorsqu’il est désactivé. Tant qu’il est activé, les nouveaux profils sont connectés automatiquement.",
appProfilesNone:
"Aucun profil de {name} n’a été détecté. Créez un profil, puis revenez sur cette page.",
appProfilesConnectFailed:
"Ces profils n’ont pas pu être connectés : {profiles}. Les autres profils sont conservés. {reason}",
appProfilesDisconnectFailed:
"Ces profils n’ont pas pu être déconnectés et peuvent encore utiliser les mémoires : {profiles}. Réessayez. {reason}",
appProfilesRevokedRetained:
"L’accès aux mémoires de ces profils a été révoqué, mais tous leurs fichiers de connexion n’ont pas été supprimés ; vérifiez-les : {profiles}.",
appProfileNameUnsupported:
"les noms de profil ne peuvent contenir que des minuscules, des chiffres et des tirets, 32 caractères au maximum",
"apps.autoHostProfilesFailed":
"Ces profils de {name} n’ont pas pu être connectés automatiquement ({profiles}){reason}.",
"Cursor 的连接凭证已更新。请完全退出并重新打开 Cursor,再开始新对话。":
"L’identifiant de connexion de Cursor a changé. Quittez complètement Cursor et rouvrez-le avant de démarrer une nouvelle conversation.",
连接其他工具: "Connecter d’autres outils",
@@ -699,6 +699,8 @@ export const indonesianMessages: Record<MessageKey, string> = {
nativeDevelopmentUnavailable: " (tidak tersedia saat pengembangan)",
nativeOpen: "Buka {product}",
nativeQuit: "Keluar dari {product} (layanan latar belakang tetap berjalan)",
nativeQuitStopsService:
"Keluar dari {product} (layanan memori juga dihentikan)",
nativeLoginError:
"Tidak dapat mengubah pengaturan mulai saat masuk. Silakan coba lagi.",
nativeMoveTitle: "Pindahkan MemHub ke Aplikasi terlebih dahulu",
@@ -1086,6 +1088,23 @@ export const indonesianMessages: Record<MessageKey, string> = {
"Akses memori telah dicabut, sehingga {name} tidak dapat lagi mencari memori. Penghapusan penuh belum dapat dikonfirmasi; tinjau jalur berikut secara manual: {paths}.",
appRemovalReview:
"Penghapusan koneksi penuh belum dapat dikonfirmasi. Tinjau jalur berikut secara manual: {paths}.",
appProfilesLabel: "Profil {name}",
appProfileOn: "{profile}: terhubung",
appProfileOff: "{profile}: belum terhubung",
appProfilesHint:
"Sakelar ini memasang koneksi memori dan memberi akses untuk setiap profil yang terdeteksi, lalu mencabut semuanya saat dimatikan. Selama sakelar menyala, profil baru akan terhubung otomatis.",
appProfilesNone:
"Tidak ada profil {name} yang terdeteksi. Buat profil, lalu kembali ke halaman ini.",
appProfilesConnectFailed:
"Profil berikut tidak dapat dihubungkan: {profiles}. Profil lainnya tetap dipertahankan. {reason}",
appProfilesDisconnectFailed:
"Profil berikut tidak dapat diputuskan dan masih dapat memakai memori: {profiles}. Coba lagi. {reason}",
appProfilesRevokedRetained:
"Akses memori untuk profil berikut telah dicabut, tetapi tidak semua file koneksinya dihapus; tinjau: {profiles}.",
appProfileNameUnsupported:
"nama profil hanya boleh berisi huruf kecil, angka, dan tanda hubung, maksimal 32 karakter",
"apps.autoHostProfilesFailed":
"Profil {name} berikut tidak dapat terhubung otomatis ({profiles}){reason}.",
"Cursor 的连接凭证已更新。请完全退出并重新打开 Cursor,再开始新对话。":
"Kredensial koneksi Cursor telah berubah. Keluar sepenuhnya lalu buka kembali Cursor sebelum memulai percakapan baru.",
连接其他工具: "Hubungkan alat lain",
@@ -697,6 +697,7 @@ export const japaneseMessages: Record<MessageKey, string> = {
nativeDevelopmentUnavailable: " (開発モードでは利用不可)",
nativeOpen: "{product} を開く",
nativeQuit: "{product} を終了(バックグラウンドサービスは引き続き実行)",
nativeQuitStopsService: "{product} を終了(メモリサービスも停止)",
nativeLoginError:
"ログイン時の起動設定を変更できませんでした。もう一度お試しください。",
nativeMoveTitle:
@@ -1083,6 +1084,23 @@ export const japaneseMessages: Record<MessageKey, string> = {
"記憶へのアクセスを取り消したため、{name} は記憶を照会できなくなりました。接続が完全に削除されたか確認できませんでした。次のパスを手動で確認してください:{paths}。",
appRemovalReview:
"接続が完全に削除されたか確認できませんでした。次のパスを手動で確認してください:{paths}。",
appProfilesLabel: "{name} のプロファイル",
appProfileOn: "{profile}:接続済み",
appProfileOff: "{profile}:未接続",
appProfilesHint:
"このスイッチは検出されたすべてのプロファイルに記憶の接続をインストールしてアクセスを許可し、オフにするとすべて取り消します。オンの間は、新しいプロファイルも自動的に接続されます。",
appProfilesNone:
"{name} のプロファイルが検出されませんでした。プロファイルを作成してから、このページに戻ってください。",
appProfilesConnectFailed:
"次のプロファイルを接続できませんでした:{profiles}。ほかのプロファイルはそのまま維持されます。{reason}",
appProfilesDisconnectFailed:
"次のプロファイルの接続を解除できず、引き続き記憶を使用できます:{profiles}。もう一度お試しください。{reason}",
appProfilesRevokedRetained:
"次のプロファイルの記憶へのアクセスを取り消しましたが、接続ファイルの一部は削除されていません。確認してください:{profiles}。",
appProfileNameUnsupported:
"プロファイル名に使えるのは小文字、数字、ハイフンのみで、32 文字までです",
"apps.autoHostProfilesFailed":
"{name} の次のプロファイルを自動的に接続できませんでした({profiles}){reason}。",
"Cursor 的连接凭证已更新。请完全退出并重新打开 Cursor,再开始新对话。":
"Cursor の接続の認証情報が更新されました。Cursor を完全に終了して開き直してから、新しい会話を始めてください。",
连接其他工具: "ほかのツールを接続",
@@ -678,6 +678,7 @@ export const koreanMessages: Record<MessageKey, string> = {
nativeDevelopmentUnavailable: " (개발 모드에서 사용 불가)",
nativeOpen: "{product} 열기",
nativeQuit: "{product} 종료(백그라운드 서비스는 계속 실행)",
nativeQuitStopsService: "{product} 종료(메모리 서비스도 중지)",
nativeLoginError:
"로그인 시 실행 설정을 변경하지 못했습니다. 다시 시도하세요.",
nativeMoveTitle: "먼저 MemHub를 응용 프로그램 폴더로 옮기세요",
@@ -1060,6 +1061,23 @@ export const koreanMessages: Record<MessageKey, string> = {
"기억 접근 권한이 취소되어 {name}은(는) 더 이상 기억을 조회할 수 없습니다. 완전히 제거되었는지 확인할 수 없으니 다음 경로를 직접 확인하세요: {paths}.",
appRemovalReview:
"연결이 완전히 제거되었는지 확인할 수 없습니다. 다음 경로를 직접 확인하세요: {paths}.",
appProfilesLabel: "{name} 프로필",
appProfileOn: "{profile}: 연결됨",
appProfileOff: "{profile}: 연결 안 됨",
appProfilesHint:
"이 스위치는 감지된 모든 프로필에 기억 연결을 설치하고 접근을 허용하며, 끄면 모두 취소합니다. 켜져 있는 동안 새 프로필은 자동으로 연결됩니다.",
appProfilesNone:
"{name} 프로필이 감지되지 않았습니다. 프로필을 만든 뒤 이 페이지로 돌아오세요.",
appProfilesConnectFailed:
"다음 프로필을 연결하지 못했습니다: {profiles}. 다른 프로필은 유지됩니다. {reason}",
appProfilesDisconnectFailed:
"다음 프로필의 연결을 해제하지 못해 여전히 기억을 사용할 수 있습니다: {profiles}. 다시 시도하세요. {reason}",
appProfilesRevokedRetained:
"다음 프로필의 기억 접근 권한을 취소했지만 연결 파일이 모두 제거되지는 않았습니다. 확인하세요: {profiles}.",
appProfileNameUnsupported:
"프로필 이름에는 소문자, 숫자, 하이픈만 사용할 수 있으며 최대 32자입니다",
"apps.autoHostProfilesFailed":
"{name}의 다음 프로필을 자동으로 연결하지 못했습니다({profiles}){reason}.",
"Cursor 的连接凭证已更新。请完全退出并重新打开 Cursor,再开始新对话。":
"Cursor의 연결 자격 증명이 변경되었습니다. 새 대화를 시작하기 전에 Cursor를 완전히 종료했다가 다시 여세요.",
连接其他工具: "다른 도구 연결",
@@ -718,6 +718,8 @@ export const portugueseMessages: Record<MessageKey, string> = {
nativeOpen: "Abrir {product}",
nativeQuit:
"Sair do {product} (o serviço em segundo plano continua em execução)",
nativeQuitStopsService:
"Sair do {product} (o serviço de memória também é encerrado)",
nativeLoginError:
"Não foi possível alterar a configuração de abertura ao iniciar sessão. Tente novamente.",
nativeMoveTitle: "Mova o MemHub para Aplicativos primeiro",
@@ -1107,6 +1109,23 @@ export const portugueseMessages: Record<MessageKey, string> = {
"O acesso às memórias foi revogado, então {name} não pode mais consultar memórias. Não foi possível confirmar a remoção completa; revise estes caminhos manualmente: {paths}.",
appRemovalReview:
"Não foi possível confirmar a remoção completa da conexão. Revise estes caminhos manualmente: {paths}.",
appProfilesLabel: "Perfis de {name}",
appProfileOn: "{profile}: conectado",
appProfileOff: "{profile}: não conectado",
appProfilesHint:
"O interruptor instala a conexão de memórias e concede acesso a cada perfil detectado, e revoga todos ao ser desligado. Enquanto estiver ligado, novos perfis são conectados automaticamente.",
appProfilesNone:
"Nenhum perfil de {name} foi detectado. Crie um perfil e volte a esta página.",
appProfilesConnectFailed:
"Não foi possível conectar estes perfis: {profiles}. Os outros perfis foram mantidos. {reason}",
appProfilesDisconnectFailed:
"Não foi possível desconectar estes perfis, que ainda podem usar memórias: {profiles}. Tente novamente. {reason}",
appProfilesRevokedRetained:
"O acesso às memórias foi revogado para estes perfis, mas nem todos os arquivos de conexão foram removidos; revise-os: {profiles}.",
appProfileNameUnsupported:
"nomes de perfil só podem usar letras minúsculas, dígitos e hifens, até 32 caracteres",
"apps.autoHostProfilesFailed":
"Não foi possível conectar automaticamente estes perfis de {name} ({profiles}){reason}.",
"Cursor 的连接凭证已更新。请完全退出并重新打开 Cursor,再开始新对话。":
"A credencial de conexão do Cursor mudou. Feche o Cursor por completo e abra novamente antes de iniciar uma nova conversa.",
连接其他工具: "Conectar outras ferramentas",
@@ -705,6 +705,7 @@ export const russianMessages: Record<MessageKey, string> = {
nativeDevelopmentUnavailable: " (недоступно в режиме разработки)",
nativeOpen: "Открыть {product}",
nativeQuit: "Выйти из {product} (фоновая служба продолжит работу)",
nativeQuitStopsService: "Выйти из {product} (служба памяти тоже остановится)",
nativeLoginError:
"Не удалось изменить настройку запуска при входе. Повторите попытку.",
nativeMoveTitle: "Сначала переместите MemHub в «Программы»",
@@ -1092,6 +1093,23 @@ export const russianMessages: Record<MessageKey, string> = {
"Доступ к памяти отозван: {name} больше не может искать записи памяти. Полное удаление не подтверждено; проверьте эти пути вручную: {paths}.",
appRemovalReview:
"Полное удаление подключения не подтверждено. Проверьте эти пути вручную: {paths}.",
appProfilesLabel: "Профили {name}",
appProfileOn: "{profile}: подключён",
appProfileOff: "{profile}: не подключён",
appProfilesHint:
"Переключатель устанавливает подключение к памяти и выдаёт доступ каждому найденному профилю, а при выключении отзывает доступ у всех. Пока он включён, новые профили подключаются автоматически.",
appProfilesNone:
"Профили {name} не найдены. Создайте профиль и вернитесь на эту страницу.",
appProfilesConnectFailed:
"Не удалось подключить профили: {profiles}. Остальные профили сохранены. {reason}",
appProfilesDisconnectFailed:
"Не удалось отключить профили, они всё ещё могут использовать память: {profiles}. Повторите попытку. {reason}",
appProfilesRevokedRetained:
"Доступ к памяти для этих профилей отозван, но не все файлы подключения удалены; проверьте их: {profiles}.",
appProfileNameUnsupported:
"имя профиля может содержать только строчные буквы, цифры и дефисы, не более 32 символов",
"apps.autoHostProfilesFailed":
"Не удалось автоматически подключить эти профили {name} ({profiles}){reason}.",
"Cursor 的连接凭证已更新。请完全退出并重新打开 Cursor,再开始新对话。":
"Учётные данные подключения Cursor изменились. Полностью закройте и снова откройте Cursor, прежде чем начинать новый диалог.",
连接其他工具: "Подключить другие инструменты",
@@ -625,6 +625,7 @@ export const traditionalChineseMessages: Record<MessageKey, string> = {
nativeDevelopmentUnavailable: "(開發模式不可用)",
nativeOpen: "開啟 {product}",
nativeQuit: "結束 {product}(背景服務會繼續執行)",
nativeQuitStopsService: "結束 {product}(同時停止記憶服務)",
nativeLoginError: "無法更改登入啟動設定,請重試。",
nativeMoveTitle: "請先將 MemHub 移到「應用程式」檔案夾",
nativeMoveDetail:
@@ -990,6 +991,22 @@ export const traditionalChineseMessages: Record<MessageKey, string> = {
appRevokedReview:
"已撤銷記憶權限,{name} 已無法查詢記憶。但無法確認連線已全部移除,請手動核對以下路徑:{paths}。",
appRemovalReview: "無法確認連線已全部移除,請手動核對以下路徑:{paths}。",
appProfilesLabel: "{name} 的 Profile",
appProfileOn: "{profile}:已串接",
appProfileOff: "{profile}:未串接",
appProfilesHint:
"開關會為每個已偵測到的 Profile 安裝記憶連線並授權,關閉時全部撤銷。開關開啟期間,新建立的 Profile 會自動串接。",
appProfilesNone: "尚未偵測到 {name} 的 Profile。建立 Profile 後請回到此頁。",
appProfilesConnectFailed:
"以下 Profile 未能串接:{profiles}。其他 Profile 已保留。{reason}",
appProfilesDisconnectFailed:
"以下 Profile 的連線未能移除,記憶權限也未能撤銷,仍可使用記憶:{profiles}。請重試。{reason}",
appProfilesRevokedRetained:
"已撤銷以下 Profile 的記憶權限,但連線檔案沒有全部移除,請核對:{profiles}。",
appProfileNameUnsupported:
"Profile 名稱只能包含小寫字母、數字和連字號,最長 32 個字元",
"apps.autoHostProfilesFailed":
"{name} 的以下 Profile 未能自動串接({profiles}){reason}。",
"Cursor 的连接凭证已更新。请完全退出并重新打开 Cursor,再开始新对话。":
"Cursor 的連線憑證已更新。請完全結束並重新開啟 Cursor,再開始新對話。",
连接其他工具: "連線其他工具",
@@ -21,6 +21,7 @@ interface Options {
permissions: HostPermissions;
layouts: ReadonlyMap<string, RecallHostLayout>;
hosts?: readonly RecallHostDescriptor[];
discoverHosts?: () => Promise<readonly RecallHostDescriptor[]>;
}
/** Constructed inside the Runtime with its existing permission owner. */
@@ -28,6 +29,7 @@ export function createConnectionControlService(options: Options) {
const hostControl = createHostControlService({
permissions: options.permissions,
hosts: options.hosts ?? [],
...(options.discoverHosts ? { discover: options.discoverHosts } : {}),
});
let controller: Promise<ConnectionController | null> | null = null;
let pinned: string | null = null;
@@ -16,9 +16,18 @@ export interface RuntimeAttachment {
* `degraded`, never `healthy`. Null/absent when the receipt predates the
* field (an older Runtime), which is treated as "unknown", not "healthy". */
sidecarState?: "running" | "failed" | "stopped" | null;
/** Build the Runtime published in its receipt. `undefined` when the receipt
* predates the field (a Runtime older than every build that publishes it);
* `null` members when the Runtime does not know its own build. */
runtimeBuild?: RuntimeReceiptBuild;
health: RuntimeIdentity;
}
export interface RuntimeReceiptBuild {
buildNumber: number | null;
commit: string | null;
}
export interface RuntimeAttachmentOptions {
dataDir: string;
processIsAlive(pid: number): boolean;
@@ -63,6 +72,7 @@ interface RuntimeServiceReceipt {
status: "starting" | "running" | "stopping";
corePort: number | null;
sidecarState: "running" | "failed" | "stopped" | null;
build?: RuntimeReceiptBuild;
}
/** The Core port a *running* managed Runtime published, or null when there is
@@ -143,6 +153,9 @@ export async function discoverRuntimeAttachment(
port: receipt.port,
servicePid: service?.pid ?? null,
sidecarState: service?.sidecarState ?? null,
...(service?.build === undefined
? {}
: { runtimeBuild: Object.freeze({ ...service.build }) }),
health: Object.freeze({ ...health }),
});
}
@@ -190,6 +203,7 @@ async function readRuntimeServiceReceipt(
) {
throw new RuntimeDiscoveryError("runtime_service_receipt_invalid");
}
const build = receiptBuild(item);
return {
instanceId: item.instance_id,
pid: item.pid,
@@ -199,6 +213,28 @@ async function readRuntimeServiceReceipt(
item.sidecar === undefined
? null
: (item.sidecar as RuntimeServiceReceipt["sidecarState"]),
...(build === undefined ? {} : { build }),
};
}
/** A malformed build field reads as "unknown", never as a receipt error: the
* build only decides whether an App adopts this Runtime, and an unknown build
* makes it leave the Runtime alone. */
function receiptBuild(
item: Record<string, unknown>,
): RuntimeReceiptBuild | undefined {
if (!("build_number" in item) && !("git_commit" in item)) return undefined;
const build = item.build_number;
const commit = item.git_commit;
return {
buildNumber:
typeof build === "number" && Number.isSafeInteger(build) && build > 0
? build
: null,
commit:
typeof commit === "string" && /^[0-9a-f]{7,40}$/.test(commit)
? commit
: null,
};
}
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff. Show more