Compare commits

..
Author SHA1 Message Date
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 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 d0a4fd60f2 fix(memhub): pass every UI language to the SDK, zh-Hans included
The owner's decision for #243: a zh-Hans UI now sets
MemoryConfig.language="zh-Hans" like every other UI language, so App users
never fall back to the SDK's zh/en auto-detect. Only an unset language
(CLI/API-only installs, or 'settings language system') keeps auto-detect.
status --json pipeline.sdk_language shows zh-Hans for zh-Hans users.

Desktop now syncs the language the GUI shows (snapshot.locale), not the raw
preference: an App that follows the system sends the resolved locale
(e.g. zh-Hans on a Chinese macOS) instead of null.

Refs #243 (MemHub passes the language explicitly; the SDK's System 2
auto-detect is left unchanged for direct SDK users)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 12:35:25 +08:00
alvinfeiandClaude Opus 5.5 aae877b880 fix(memhub): accept only a full 40-character commit at the packaged-runtime boundary
resolvePackagedCommit accepted 7-40 hex characters in any case, while every
downstream check (packaged-runtime install, local install, release download,
release scripts, after-pack) requires 40 lowercase hex characters. It now
accepts exactly 40 hex characters, case-insensitive, returns them lowercased,
and skips a short or malformed value so a bad ONE_MEMORY_COMMIT no longer
shadows the embedded commit. The tsup build fails on a malformed supplied
ONE_MEMORY_COMMIT instead of baking it.

Fixes #236, Fixes #235

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 12:35:14 +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
feitianxiang 670c7c6cb1 Merge pull request #267 from Tencent-Hunyuan/docs/release-1.0.6
docs: prepare MemHub v1.0.6 (news, changelog)
2026-11-02 11:50:58 +08:00
96 changed files with 4811 additions and 346 deletions

No files matched your search

+60
View File
@@ -4,6 +4,66 @@ Hy Memory and MemHub are versioned and released independently.
## Unreleased
- MemHub multi-user hardening (follow-up to #250):
- Read responses name the user they were read for: `user_id` (the one
asked for, `local` by default) is now on `GET /api/v2/memory-library`,
`/memory-library/layers`, `/user-profile`, `/home-summary`,
`/memories/graph`, `/raw`, and `POST /api/v2/context/search`,
`/api/v1/recall`, `/api/v2/memories/ask`. `ask/stream` carries it on its
first (`recall`) event. The Host Bridge context validator accepts the new
field and still accepts a Core without it.
- `DELETE /api/v2/memories/{id}` answers 404 `memory_not_found` for another
user's memory and for an id that does not exist (the same response, so
existence is still not revealed), and also when the SDK finds nothing to
delete; 204 only when the caller's own memory was deleted. The App treats
that 404 as already gone (a double-click delete no longer shows an
error); `memhub memories delete` prints "未找到记忆 <id>(可能已删除)",
exits 0, and its JSON is `state: "committed"` with `already_gone: true`.
- Hermes never reads a `profiles/` directory at any depth (scan, detect and
live sync), so a root granted above `~/.hermes` cannot pick up another
user's profile. Connecting refuses a root that is the `profiles`
container itself (422 `hermes_root_is_profiles_container`) and a root
equal to, containing or inside another user's active Hermes grant (409
`hermes_root_overlaps_other_user`, symlinks resolved); `local` on
`~/.hermes` with another user on `~/.hermes/profiles/<name>` stays
allowed. The CLI explains both codes.
- `memhub sources connect --user-id` with an invalid id now says the user
id need not equal the Hermes profile name (e.g. `--user-id wife-work
--root ~/.hermes/profiles/wife:work`).
- MemHub'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.
- SDK: 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 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 与历史
| 变量 | 默认值 | 说明 |
+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,
+6 -1
View File
@@ -635,6 +635,7 @@ class MemoryWriter(WritePipeline):
不查 VDB、不做"新写入 vs 库里已有"的对比,避免给 VDB 增加读压力。
库内存量污染由 recall 侧异步删除逐步清理。best-effort:任何异常都
回退到"不判重、全写入",绝不影响 upsert 主流程。
`recall.intention_dedup_enabled=False` 时跳过批内判重,全部写入。
"""
if not intentions:
return []
@@ -660,7 +661,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
+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
+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" &&
+3 -3
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: [
+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,
@@ -30,8 +30,7 @@ vi.mock("@one-memory/runtime-control", async (importOriginal) => ({
}));
function languageState(uiLanguage: string | null, extra = {}) {
const sdk =
uiLanguage === null || uiLanguage === "zh-Hans" ? "auto" : uiLanguage;
const sdk = uiLanguage === null ? "auto" : uiLanguage;
return {
ui_language: uiLanguage,
sdk_language: sdk,
@@ -95,7 +94,7 @@ describe("memhub settings language", () => {
});
expect(transport.put).not.toHaveBeenCalled();
const text = (await capturedRun(["settings", "language"])).stdout;
expect(text).toContain("界面语言:跟随系统(未选择)");
expect(text).toContain("界面语言:未设置(SDK 自动识别)");
expect(text).toContain("SDK 提示语言:auto");
});
+7 -6
View File
@@ -2,10 +2,11 @@
* `memhub settings language [<code>|system]`.
*
* The display language the App reports to the Runtime (`ui_language`), and
* the SDK prompt language it implies. zh-Hans and "system" (no explicit
* choice) pass no language, so the SDK keeps detecting Chinese or English as
* before; every other code, en included, makes extraction and the other SDK
* prompts use that language. A change applies at once: a write in flight
* the SDK prompt language it implies. Every code, zh-Hans and en included,
* makes extraction and the other SDK prompts use that language; "system"
* (no language set) passes none, so the SDK keeps detecting Chinese or
* English as before. The App sends the language it shows, so it is only
* unset on an install without the App. A change applies at once: a write in flight
* finishes in the old language, the next ones use the new one. The App sends
* its own choice again whenever it changes there.
*/
@@ -162,14 +163,14 @@ export function renderLanguageText(document: LanguageDocument): string {
const lines = [
`界面语言:${
state.ui_language === null
? "跟随系统(未选择)"
? "未设置(SDK 自动识别)"
: `${state.ui_language}(${NAMES[state.ui_language]})`
}`,
`SDK 提示语言:${sdkText(state.sdk_language)}`,
];
if (document.state === "ok") {
lines.push(
`可选:${UI_LANGUAGES.join("、")}、system。简体中文和 system 不指定语言,其余语言(包括 en)会让 SDK 使用该语言。`,
`可选:${UI_LANGUAGES.join("、")}、system。任一语言(包括 zh-Hans、en)都会让 SDK 使用该语言;system 不指定语言,SDK 自动识别中文或英文。`,
);
}
if (document.state === "unchanged") lines.push("没有需要改动的设置。");
+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[]) {
+111 -17
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,
@@ -526,11 +532,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 +605,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 +620,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)
@@ -766,7 +790,7 @@ class PipelineStatus(BaseModel):
#: default; empty while every feature is at its default.
features: dict[MemoryFeatureName, bool] = Field(default_factory=dict)
#: The display language the App last reported (`ui_language`); null =
#: never chosen or "follow the system".
#: none set (the App sends the language it shows, even following the system).
ui_language: UiLanguage | None = None
#: The SDK prompt language: `auto` (the SDK detects zh/en) or a code.
sdk_language: str = SDK_LANGUAGE_AUTO
@@ -777,9 +801,9 @@ class UiLanguageState(BaseModel):
model_config = ConfigDict(extra="forbid")
#: What the App reported; null = never chosen or "follow the system".
#: What the App reported; null = none set (CLI/API-only, or `system`).
ui_language: UiLanguage | None
#: What SDK clients are built with: `auto` (zh-Hans or no choice: the SDK
#: What SDK clients are built with: `auto` (no language set: the SDK
#: detects zh/en, as before) or the code passed as `MemoryConfig.language`.
sdk_language: str
languages: tuple[UiLanguage, ...] = UI_LANGUAGES
@@ -967,6 +991,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 +1019,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 +2695,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 +3096,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 +3209,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 +3274,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 +3810,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 +3818,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 +3835,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 +3881,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 +3894,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 +3912,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 +3958,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 +4119,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 +4141,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 +4265,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 +4283,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 +4360,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 +4393,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 +4435,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 +4516,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",
@@ -4616,9 +4710,9 @@ def create_app(
async def put_ui_language(request: UiLanguageUpdate) -> UiLanguageChange:
"""Record the App's display language; one global setting, not per user.
zh-Hans and null pass no language to the SDK, which keeps detecting
zh/en as before; every other code (en included) is passed as
`MemoryConfig.language`. A change of that SDK language rebuilds the
Every code (zh-Hans and en included) is passed as
`MemoryConfig.language`; only null passes no language, so the SDK
keeps detecting zh/en as before. A change of that SDK language rebuilds the
SDK clients like a feature change: a write in flight finishes in the
old language. Nothing stored is migrated and no collection is renamed.
"""
@@ -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
@@ -367,8 +367,8 @@ def language_kwargs(language: str | None) -> LanguageKwargs:
def apply_sdk_language(config: MemoryConfig, language: str | None) -> MemoryConfig:
"""Set the SDK prompt language (`MemoryConfig.language`), when there is one.
None (zh-Hans, or no choice) sets nothing: the SDK detects zh/en as it
always did. Like a feature, the language never touches `vector_store`, so
None (no language set) sets nothing: the SDK detects zh/en as it always
did. Every display language, zh-Hans included, is set explicitly. Like a feature, the language never touches `vector_store`, so
the collection identity never depends on it.
"""
if language is not None:
@@ -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,
@@ -74,9 +74,9 @@ class ConfigDocument(BaseModel):
#: SDK retrieval and extraction features a person set; an unset one keeps
#: the SDK default. See `one_memory.memory_features`.
memory_features: MemoryFeatures = Field(default_factory=MemoryFeatures)
#: The display language a person chose in the App; None = never chosen or
#: "follow the system". See `one_memory.ui_language` for what it does to
#: the SDK.
#: The display language the App shows (its choice, or the system language
#: it follows); None = none set. See `one_memory.ui_language` for what it
#: does to the SDK.
ui_language: UiLanguage | None = None
@field_validator("sources")
@@ -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:
+16 -12
View File
@@ -3,14 +3,19 @@
The App owns the choice (its language selector) and tells Core whenever it
changes, so Core can build every SDK client with it. It is one global value in
`config.json` (`ui_language`), beside `memory_mode` and `memory_features`;
absent (never chosen, or "follow the system") means no explicit choice.
absent means no language was set (a CLI/API-only install, or
`settings language system`). An App that follows the system sends the
language it resolved and shows, not null.
The owner's rule for the SDK:
- zh-Hans, or no explicit choice: pass no language. The SDK keeps detecting
Chinese or English from the text, exactly as before this setting existed.
- Any other code, including en: pass it as `MemoryConfig.language`, so
extraction and the other prompts use that language.
- Any supported code, zh-Hans and en included: pass it as
`MemoryConfig.language`, so extraction and the other prompts use that
language. Every display language is passed explicitly (#243).
- No language at all (never set, as on a CLI/API-only install): pass no
language. The SDK keeps detecting Chinese or English from the text, exactly
as before this setting existed. The App does not leave it unset: it sends
the language it shows, including when it follows the system.
The language never names a collection; a change rebuilds the SDK handles the
way a mode or feature change does (#218 use count).
@@ -51,8 +56,6 @@ UI_LANGUAGES: tuple[UiLanguage, ...] = (
)
#: Codes earlier App builds stored, and the code each one is now.
LEGACY_UI_LANGUAGES: dict[str, UiLanguage] = {"zh-CN": "zh-Hans"}
#: Display languages that leave the SDK's own zh/en detection in charge.
AUTO_DETECTED: frozenset[str] = frozenset({"zh-Hans"})
#: What status shows when no language is passed to the SDK.
SDK_LANGUAGE_AUTO = "auto"
@@ -66,8 +69,9 @@ def normalize_ui_language(value: object) -> UiLanguage | None:
def sdk_language(ui_language: str | None) -> str | None:
"""The `MemoryConfig.language` for a display language; None = SDK auto-detect."""
language = normalize_ui_language(ui_language)
if language is None or language in AUTO_DETECTED:
return None
return language
"""The `MemoryConfig.language` for a display language; None = SDK auto-detect.
Every supported code is passed as is; only an unset (or unknown) value
leaves the SDK to detect zh/en.
"""
return normalize_ui_language(ui_language)
+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",
@@ -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)
+32 -17
View File
@@ -1,8 +1,8 @@
"""The App's display language, and the SDK prompt language it implies.
The owner's rule: zh-Hans, or no explicit choice, passes no language, so the
SDK keeps detecting zh/en exactly as before; every other code (en included)
is passed as `MemoryConfig.language` (hy-memory 2.0.11). A change rebuilds the
The owner's rule (#243): every display language, zh-Hans and en included, is
passed as `MemoryConfig.language` (hy-memory 2.0.11); only an unset language
passes none, so the SDK keeps detecting zh/en exactly as before. A change rebuilds the
handles the way a mode or feature change does, and the language never touches
the vector collection identity.
"""
@@ -28,8 +28,8 @@ from one_memory.secrets import InMemorySecretStore
from one_memory.ui_language import UI_LANGUAGES, normalize_ui_language, sdk_language
TOKEN = "ui-language-test"
#: Every code except zh-Hans is passed to the SDK.
PASSED = ("en", "zh-Hant", "ja", "es", "de", "pt", "fr", "ko", "ru", "id")
#: Every code is passed to the SDK, zh-Hans included.
PASSED = ("en", "zh-Hans", "zh-Hant", "ja", "es", "de", "pt", "fr", "ko", "ru", "id")
@pytest.fixture(autouse=True)
@@ -51,7 +51,7 @@ def _config(tmp_path: Path, language: str | None):
def test_eleven_codes_and_the_legacy_chinese_code() -> None:
assert ("en", "zh-Hans", *PASSED[1:]) == UI_LANGUAGES
assert PASSED == UI_LANGUAGES
assert len(UI_LANGUAGES) == 11
# Earlier App builds stored zh-CN; it is zh-Hans now.
assert normalize_ui_language("zh-CN") == "zh-Hans"
@@ -60,19 +60,20 @@ def test_eleven_codes_and_the_legacy_chinese_code() -> None:
assert normalize_ui_language(None) is None
def test_only_zh_hans_and_no_choice_leave_the_sdk_to_detect() -> None:
def test_only_an_unset_language_leaves_the_sdk_to_detect() -> None:
assert sdk_language(None) is None
assert sdk_language("zh-Hans") is None
assert sdk_language("zh-CN") is None
assert sdk_language("system") is None
assert sdk_language("xx") is None
# zh-Hans is passed like every other code; the legacy zh-CN maps to it.
assert sdk_language("zh-Hans") == "zh-Hans"
assert sdk_language("zh-CN") == "zh-Hans"
for code in PASSED:
assert sdk_language(code) == code
def test_zh_hans_and_no_choice_build_the_config_of_before(tmp_path) -> None:
before = repr(dataclasses.asdict(_config(tmp_path, None)))
def test_no_language_builds_the_config_of_before(tmp_path) -> None:
assert _config(tmp_path, None).language is None
assert repr(dataclasses.asdict(_config(tmp_path, sdk_language("zh-Hans")))) == before
assert _config(tmp_path, sdk_language("zh-Hans")).language == "zh-Hans"
@pytest.mark.parametrize("code", PASSED)
@@ -282,17 +283,31 @@ def test_ui_language_api_get_put_and_status(tmp_path, monkeypatch) -> None:
assert current["languages"] == list(UI_LANGUAGES)
assert reads.current_language() is None and writes.sdk_language() is None
# zh-Hans: recorded, but the SDK still detects zh/en; nothing rebuilt.
# zh-Hans is passed explicitly: Chinese prompts, handles rebuilt (#243).
body = (
await client.put("/api/v1/settings/ui-language", json={"ui_language": "zh-Hans"})
).json()
assert body == {**current, "ui_language": "zh-Hans", "rebuilt": False}
assert retired == [] and reads.current_language() is None
assert body == {
**current,
"ui_language": "zh-Hans",
"sdk_language": "zh-Hans",
"rebuilt": True,
}
assert reads.current_language() == "zh-Hans" and writes.sdk_language() == "zh-Hans"
assert retired == ["zh-Hans"]
processing = (await client.get("/api/v1/memory-processing")).json()
assert processing["pipeline"]["sdk_language"] == "zh-Hans"
# The same value again changes nothing.
body = (
await client.put("/api/v1/settings/ui-language", json={"ui_language": "zh-Hans"})
).json()
assert body["rebuilt"] is False and retired == ["zh-Hans"]
body = (await client.put("/api/v1/settings/ui-language", json={"ui_language": "ja"})).json()
assert body["sdk_language"] == "ja" and body["rebuilt"] is True
assert reads.current_language() == "ja" and writes.sdk_language() == "ja"
assert retired == ["ja"]
assert retired == ["zh-Hans", "ja"]
# An explicit English choice forces English prompts.
body = (await client.put("/api/v1/settings/ui-language", json={"ui_language": "en"})).json()
@@ -302,7 +317,7 @@ def test_ui_language_api_get_put_and_status(tmp_path, monkeypatch) -> None:
assert processing["pipeline"]["ui_language"] == "en"
assert processing["pipeline"]["sdk_language"] == "en"
# Back to "follow the system": auto again.
# Unset (CLI `settings language system`): auto again.
body = (await client.put("/api/v1/settings/ui-language", json={"ui_language": None})).json()
assert body["sdk_language"] == "auto" and body["rebuilt"] is True
assert reads.current_language() is None
@@ -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();
},
@@ -15,9 +15,13 @@ it("bakes fallback release identity when the packaging pipeline omits it", async
expect(config).toMatch(
/__ONE_MEMORY_BUILD_NUMBER__: JSON\.stringify\(\s*String\(releaseBuildNumber\)/,
);
expect(config).toMatch(
/process\.env\.ONE_MEMORY_COMMIT\?\.trim\(\) \|\| readSourceCommit\(\)/,
expect(config).toContain(
"const releaseCommit = resolveReleaseCommit(process.env.ONE_MEMORY_COMMIT)",
);
expect(config).toMatch(/if \(!trimmed\) return readSourceCommit\(\);/);
// A supplied commit must be the full 40-character form the runtime accepts.
expect(config).toContain("/^[0-9a-f]{40}$/i.test(trimmed)");
expect(config).toContain("return trimmed.toLowerCase();");
expect(config).toMatch(
/execFileSync\("git", \["rev-parse", "HEAD"\],[\s\S]*?encoding: "utf8"/,
);
@@ -1,4 +1,4 @@
import type { LanguageSnapshot } from "@one-memory/i18n";
import type { GuiLocale, LanguageSnapshot } from "@one-memory/i18n";
import { describe, expect, it, vi } from "vitest";
import { coreUiLanguage, installCoreLanguageSync } from "./core-language-sync";
@@ -21,11 +21,16 @@ function source(initial: LanguageSnapshot) {
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
describe("Core language sync", () => {
it("tells Core null for system and the code for an explicit choice", () => {
expect(coreUiLanguage("system")).toBeNull();
expect(coreUiLanguage("zh-Hans")).toBe("zh-Hans");
expect(coreUiLanguage("en")).toBe("en");
expect(coreUiLanguage("ja")).toBe("ja");
it("tells Core the language the GUI shows, system included", () => {
const shown = (
preference: LanguageSnapshot["preference"],
locale: GuiLocale,
) => coreUiLanguage({ preference, locale, revision: 0 });
expect(shown("system", "zh-Hans")).toBe("zh-Hans");
expect(shown("system", "en")).toBe("en");
expect(shown("zh-Hans", "zh-Hans")).toBe("zh-Hans");
expect(shown("en", "en")).toBe("en");
expect(shown("ja", "ja")).toBe("ja");
});
it("sends the stored choice once at launch, then every change", async () => {
@@ -38,29 +43,33 @@ describe("Core language sync", () => {
await settle();
language.change({ preference: "system", locale: "en", revision: 2 });
await settle();
expect(send.mock.calls).toEqual([["es"], ["ja"], [null]]);
expect(send.mock.calls).toEqual([["es"], ["ja"], ["en"]]);
dispose();
language.change({ preference: "de", locale: "de", revision: 3 });
await settle();
expect(send).toHaveBeenCalledTimes(3);
});
it("an untouched install sends null, which leaves the SDK on auto", async () => {
it("an untouched install sends the system language the GUI shows", async () => {
const language = source({
preference: "system",
locale: "ja",
locale: "zh-Hans",
revision: 0,
});
const send = vi.fn(async () => {});
installCoreLanguageSync({ language, send });
await settle();
expect(send.mock.calls).toEqual([[null]]);
expect(send.mock.calls).toEqual([["zh-Hans"]]);
// Choosing the language already shown changes nothing Core sees.
language.change({ preference: "zh-Hans", locale: "zh-Hans", revision: 1 });
await settle();
expect(send).toHaveBeenCalledTimes(1);
});
it("reports a failed send and retries it with the next change", async () => {
const language = source({ preference: "fr", locale: "fr", revision: 0 });
const send = vi
.fn<(value: string | null) => Promise<void>>()
.fn<(value: string) => Promise<void>>()
.mockRejectedValueOnce(new Error("core_unavailable"))
.mockResolvedValue(undefined);
const reportError = vi.fn();
+18 -16
View File
@@ -1,35 +1,37 @@
import type { GuiLocale, LanguagePreference } from "@one-memory/i18n";
import type { GuiLocale, LanguageSnapshot } from "@one-memory/i18n";
import type { NativeLanguageSource } from "./native-language";
/** What Core is told: a code, or null for "follow the system". */
export function coreUiLanguage(
preference: LanguagePreference,
): GuiLocale | null {
return preference === "system" ? null : preference;
/**
* What Core is told: the language the GUI shows. "Follow the system" sends
* the language it resolved to, so the memories match the interface a person
* sees; the App never leaves the SDK to detect zh/en (#243).
*/
export function coreUiLanguage(snapshot: LanguageSnapshot): GuiLocale {
return snapshot.locale;
}
/**
* Keep Core's `ui_language` setting on the display language a person chose.
* Keep Core's `ui_language` setting on the display language the GUI shows.
*
* Core builds every SDK client with it: zh-Hans and "follow the system" pass
* no language (the SDK keeps detecting zh/en), any other choice sets the SDK
* prompt language. Sent once when installed, so a choice made before this
* existed reaches Core, then on every committed change; a value Core already
* has is not sent again. Best effort: a failed send is reported and retried
* with the next change or launch, and never blocks the GUI.
* Core builds every SDK client with it and passes it, zh-Hans included, as
* the SDK prompt language. Sent once when installed, so a choice made before
* this existed, or the system language followed at this launch, reaches
* Core; then on every committed change. A value Core already has is not sent
* again. Best effort: a failed send is reported and retried with the next
* change or launch, and never blocks the GUI.
*/
export function installCoreLanguageSync(options: {
language: NativeLanguageSource;
send(uiLanguage: GuiLocale | null): Promise<void>;
send(uiLanguage: GuiLocale): Promise<void>;
reportError?(error: unknown): void;
}): () => void {
let closed = false;
let sent: GuiLocale | null | undefined;
let sent: GuiLocale | undefined;
let queue: Promise<void> = Promise.resolve();
const push = () => {
queue = queue.then(async () => {
if (closed) return;
const value = coreUiLanguage(options.language.read().preference);
const value = coreUiLanguage(options.language.read());
if (value === sent) return;
try {
await options.send(value);
@@ -526,7 +526,22 @@ describe("hourly isolated desktop launch", () => {
expect(resolvePackagedBuildNumber("-1")).toBeUndefined();
expect(resolvePackagedBuildNumber(undefined, "3907")).toBe(3907);
expect(resolvePackagedBuildNumber("3908", "3907")).toBe(3908);
expect(resolvePackagedCommit(undefined, "388fd0e9")).toBe("388fd0e9");
expect(resolvePackagedCommit("not-a-sha", "388fd0e9")).toBe("388fd0e9");
});
it("resolves only a full 40-character commit and lowercases it", () => {
// Every downstream check (packaged-runtime install, local install, release
// download, release scripts) requires 40 lowercase hex characters.
const embedded = "388fd0e9b3c41a2d5e6f7a8b9c0d1e2f3a4b5c6d";
expect(resolvePackagedCommit(undefined, embedded)).toBe(embedded);
expect(resolvePackagedCommit(embedded.toUpperCase())).toBe(embedded);
expect(
resolvePackagedCommit(` ${embedded.toUpperCase()} `, undefined),
).toBe(embedded);
expect(resolvePackagedCommit("388fd0e9", embedded)).toBe(embedded);
expect(resolvePackagedCommit("388fd0e9")).toBeUndefined();
expect(resolvePackagedCommit("not-a-sha", embedded)).toBe(embedded);
expect(resolvePackagedCommit(`${embedded}0`, embedded)).toBe(embedded);
expect(resolvePackagedCommit("g".repeat(40), embedded)).toBe(embedded);
expect(resolvePackagedCommit(undefined, "unknown")).toBeUndefined();
});
});
+7 -1
View File
@@ -143,6 +143,12 @@ export function resolvePackagedBuildNumber(
return undefined;
}
/**
* The packaged-runtime install, local install, release download and release
* scripts all require a full 40-character lowercase commit. Normalise here so
* that one format crosses this boundary: an uppercase full SHA is lowercased,
* and a short or malformed value is skipped so it cannot shadow the next source.
*/
export function resolvePackagedCommit(
environmentValue: unknown,
embeddedValue?: unknown,
@@ -150,7 +156,7 @@ export function resolvePackagedCommit(
for (const value of [environmentValue, embeddedValue]) {
if (typeof value !== "string") continue;
const trimmed = value.trim();
if (/^[0-9a-f]{7,40}$/i.test(trimmed)) return trimmed;
if (/^[0-9a-f]{40}$/i.test(trimmed)) return trimmed.toLowerCase();
}
return undefined;
}
@@ -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 启动失败。请重新启动;如果仍然打不开,请重新安装,你的记忆数据不会受到影响。",
};
+14 -2
View File
@@ -11,8 +11,20 @@ const { resolveBuildNumber, resolveBuildVersion } =
};
const releaseVersion = resolveBuildVersion(process.env);
const releaseBuildNumber = resolveBuildNumber(process.env);
const releaseCommit =
process.env.ONE_MEMORY_COMMIT?.trim() || readSourceCommit();
const releaseCommit = resolveReleaseCommit(process.env.ONE_MEMORY_COMMIT);
// The runtime accepts only a full 40-character commit, so an explicitly
// supplied malformed value fails the build instead of being baked and skipped.
function resolveReleaseCommit(explicit: string | undefined): string {
const trimmed = explicit?.trim();
if (!trimmed) return readSourceCommit();
if (!/^[0-9a-f]{40}$/i.test(trimmed)) {
throw new Error(
`ONE_MEMORY_COMMIT must be a full 40-character hex commit, got ${JSON.stringify(trimmed)}`,
);
}
return trimmed.toLowerCase();
}
function readSourceCommit(): string {
try {
@@ -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", () => {
+16 -6
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`)
@@ -189,14 +199,14 @@ node apps/cli/dist/one-memory.cjs settings features reset
```bash
node apps/cli/dist/one-memory.cjs settings language # 界面语言和 SDK 提示语言
node apps/cli/dist/one-memory.cjs settings language ja # 设为日语,立即生效
node apps/cli/dist/one-memory.cjs settings language system # 跟随系统(不指定 SDK 语言)
node apps/cli/dist/one-memory.cjs settings language system # 清除设置(不指定 SDK 语言)
```
- 可选 11 种:`en`、`zh-Hans`、`zh-Hant`、`ja`、`es`、`de`、`pt`、`fr`、`ko`、`ru`、`id`,以及 `system`;旧代码 `zh-CN` 按 `zh-Hans` 处理。
- App 切换语言时会自动设置(Desktop 启动时也会同步一次已保存的选择)。全局设置,存于 `config.json` 的 `ui_language`;从未选择时不写入。
- `zh-Hans` 和 `system` 不向 SDK 传语言,SDK 照旧自动识别中文或英文;其余语言(包括 `en`)作为 `MemoryConfig.language` 传给 SDK,提取等提示使用该语言。
- App 切换语言时会自动设置;Desktop 每次启动也会同步一次界面实际显示的语言(App 选“跟随系统”时,发送它按系统语言解析出的语言,例如 `zh-Hans`)。全局设置,存于 `config.json` 的 `ui_language`;从未设置时不写入。
- 所有语言(包括 `zh-Hans` 和 `en`)都作为 `MemoryConfig.language` 传给 SDK,提取等提示使用该语言;只有未设置(`system`,或只用 CLI/API、从未打开 App 的安装)时不向 SDK 传语言,SDK 照旧自动识别中文或英文。
- 修改会像功能开关一样重建 SDK 客户端;不迁移数据,不改集合名(需要 hy-memory 2.0.11 的 `MemoryConfig.language`)。
- `status --json` 的 `pipeline.ui_language` / `pipeline.sdk_language`(`auto` 或语言代码)。API:`GET`/`PUT /api/v1/settings/ui-language`。
- `status --json` 的 `pipeline.ui_language` / `pipeline.sdk_language`(`auto` 或语言代码;`zh-Hans` 用户显示 `zh-Hans`)。API:`GET`/`PUT /api/v1/settings/ui-language`。
## 8. 召回(recall)
@@ -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`;
@@ -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,
};
}
@@ -194,6 +194,39 @@ it.each([
expect(f.effects).toEqual([]);
},
);
it("finds a Harness profile created after the Runtime started, and only a profile", async () => {
const f = await fixture();
const hostId = "deepseek-harness:later";
const later = {
hostId,
label: "DeepSeek Harness · later",
layout: { ...f.layout, hostId, hostLabel: "DeepSeek Harness · later" },
presencePath: f.root,
};
const discover = vi.fn(async () => [...f.hosts, later]);
const service = createHostControlService({
permissions: f.permissions as unknown as HostPermissions,
hosts: f.hosts,
discover,
});
await service.handle({ hostId, operation: "getPermission", args: [] });
expect(f.methods.getPermission).toHaveBeenCalledWith(later.layout);
// Remembered: the disk is read once per new profile, not once per call.
await service.handle({ hostId, operation: "getPermission", args: [] });
expect(discover).toHaveBeenCalledTimes(1);
await expect(
service.handle({
hostId: "deepseek-harness:absent",
operation: "getPermission",
args: [],
}),
).rejects.toThrow();
expect(discover).toHaveBeenCalledTimes(2);
await expect(
service.handle({ hostId: "unknown", operation: "getPermission", args: [] }),
).rejects.toThrow();
expect(discover).toHaveBeenCalledTimes(2);
});
it("clones nested arguments and trusted layout before an asynchronous owner observes them", async () => {
const f = await fixture();
let release!: () => void;
@@ -149,6 +149,8 @@ function validateArgs(operation: HostControlOperation, args: unknown[]): void {
export function createHostControlService(options: {
permissions: HostPermissions;
hosts: readonly RecallHostDescriptor[];
/** Re-reads the disk for a profile host created since this service started. */
discover?: () => Promise<readonly RecallHostDescriptor[]>;
}) {
const permissions = options.permissions;
const hosts = new Map<string, RecallHostDescriptor>();
@@ -158,6 +160,17 @@ export function createHostControlService(options: {
fail();
hosts.set(source.hostId, structuredClone(source));
}
/** Only a profile id can appear after start; every other host is a fixed
* path, so an unknown one is refused without touching the disk. */
async function discovered(hostId: string) {
if (!options.discover || !hostId.includes(":")) return undefined;
const found = (await options.discover()).find(
(source) =>
source.hostId === hostId && source.layout.hostId === source.hostId,
);
if (found) hosts.set(hostId, structuredClone(found));
return found;
}
return {
async handle(input: unknown): Promise<unknown> {
const request = exact(input, ["hostId", "operation", "args"]);
@@ -169,7 +182,10 @@ export function createHostControlService(options: {
!Array.isArray(request.args)
)
fail();
const host = hosts.get(request.hostId);
// A known host must not yield here: its arguments are cloned below,
// before the caller's next tick can change them.
const host =
hosts.get(request.hostId) ?? (await discovered(request.hostId));
if (!host) fail();
const operation = request.operation as HostControlOperation;
if (
@@ -71,6 +71,7 @@ export {
type RuntimeAttachmentOptions,
RuntimeDiscoveryError,
type RuntimeIdentity,
type RuntimeReceiptBuild,
readPublishedCorePort,
} from "./discovery.js";
export {
@@ -144,12 +145,17 @@ export {
type RuntimeCliConfig,
resolveRuntimeCliConfig,
runRuntimeCli,
runtimeBuildIdentity,
} from "./runtime-cli.js";
export {
type EnsureRuntimeOptions,
ensureRuntimeProcess,
type RuntimeLaunch,
readRuntimeLeaseOwnerPid,
type StopAllRuntimesOptions,
type StopAllRuntimesResult,
type StopRuntimeOptions,
stopAllRuntimeProcesses,
stopRuntimeProcess,
type WaitForRuntimeOptions,
waitForRuntimeProcess,
@@ -160,6 +166,7 @@ export {
} from "./runtime-maintenance.js";
export {
type ManagedRuntime,
type RuntimeBuildIdentity,
RuntimeService,
type RuntimeServiceOptions,
} from "./runtime-service.js";
@@ -235,6 +242,15 @@ export {
runtimeInstallLayout,
stableRuntimeInstallRoot,
} from "./service-plan.js";
export {
parseLaunchctlPid,
queryRuntimeServiceState,
type RuntimeServiceState,
type RuntimeServiceStateName,
type RuntimeServiceStateOptions,
type RuntimeServiceStateReason,
type ServiceStateProbe,
} from "./service-state.js";
export { createSourceChangeHandler } from "./source-change-handler.js";
export {
type ApplySyncHookInput,
@@ -24,6 +24,7 @@ import {
resolveRuntimeCliConfig,
revalidateHostRuntime,
runRuntimeCli,
runtimeBuildIdentity,
waitForAppRemoval,
} from "./runtime-cli.js";
import {
@@ -1030,3 +1031,18 @@ describe("Runtime CLI service plan", () => {
);
});
});
describe("the build a Runtime publishes in its receipt", () => {
it("is its build number and commit, and null where it does not know", () => {
expect(
runtimeBuildIdentity({ buildNumber: "38", commit: "ABCDEF1234" }),
).toEqual({ buildNumber: 38, commit: "abcdef1234" });
expect(runtimeBuildIdentity(undefined)).toEqual({
buildNumber: null,
commit: null,
});
expect(
runtimeBuildIdentity({ buildNumber: "dev", commit: "unknown" }),
).toEqual({ buildNumber: null, commit: null });
});
});
@@ -18,7 +18,10 @@ import { HostPermissions } from "./host-permissions.js";
import { verifyInstalledDistribution } from "./local-install.js";
import { RecallBrokerSupervisor } from "./recall-broker-supervisor.js";
import { recallHosts } from "./recall-hosts.js";
import { RuntimeService } from "./runtime-service.js";
import {
type RuntimeBuildIdentity,
RuntimeService,
} from "./runtime-service.js";
import { RuntimeSupervisor } from "./runtime-supervisor.js";
import {
runSdkMigration,
@@ -653,6 +656,19 @@ function boundFingerprint(
.digest("hex");
}
/** The build this Runtime publishes in its receipt. Unknown stays `null`, never
* a guess: an App treats an unknown build as "do not touch". */
export function runtimeBuildIdentity(
identity: RuntimeCliConfig["buildIdentity"],
): RuntimeBuildIdentity {
const build = Number(identity?.buildNumber);
const commit = identity?.commit?.trim().toLowerCase() ?? "";
return {
buildNumber: Number.isSafeInteger(build) && build > 0 ? build : null,
commit: /^[0-9a-f]{7,40}$/.test(commit) ? commit : null,
};
}
export async function createRuntimeService(
config: RuntimeCliConfig,
): Promise<RuntimeService> {
@@ -668,6 +684,7 @@ export async function createRuntimeService(
return new RuntimeService({
dataDir: config.dataDir,
diagnostics,
buildIdentity: runtimeBuildIdentity(config.buildIdentity),
async createSupervisor(onSidecarFault) {
const { port, overrideApplied, ignoredReason } = resolveCorePort(
process.env,
@@ -732,12 +749,14 @@ export async function createRuntimeService(
verifyHostRuntime,
config.hostRuntime?.manifestSha256 ?? "daily",
);
const hosts = (
await recallHosts(config.hostHome ?? os.homedir(), {
...process.env,
ONE_MEMORY_HOST_HOME: config.hostHome,
})
).filter((host) => host.hostId !== "openclaw");
const scanHosts = async () =>
(
await recallHosts(config.hostHome ?? os.homedir(), {
...process.env,
ONE_MEMORY_HOST_HOME: config.hostHome,
})
).filter((host) => host.hostId !== "openclaw");
const hosts = await scanHosts();
await verifyHostRuntime().then(
async () => {
// Every Runtime start after an upgrade, the App's or `memhub
@@ -767,6 +786,9 @@ export async function createRuntimeService(
connection,
permissions,
hosts,
// The Runtime outlives the App: a Harness profile created after it
// started must not make every later host read fail.
discoverHosts: scanHosts,
layouts: new Map(
hosts.flatMap((host) =>
host.hostId === "claude-code"
@@ -1,12 +1,13 @@
import type { ChildProcess, SpawnOptions } from "node:child_process";
import { spawn } from "node:child_process";
import { access } from "node:fs/promises";
import { access, readFile } from "node:fs/promises";
import path from "node:path";
import type { HealthResponse } from "@one-memory/contracts";
import axios from "axios";
import { processIsAlive } from "./core-supervisor.js";
import {
discoverRuntimeAttachment,
type RuntimeAttachment,
RuntimeDiscoveryError,
} from "./discovery.js";
import { readSdkMigrationBlock } from "./sdk-migration-log.js";
@@ -109,7 +110,7 @@ export async function ensureRuntimeProcess(
export async function waitForRuntimeProcess(
options: WaitForRuntimeOptions,
): Promise<void> {
): Promise<RuntimeAttachment> {
const attempts = options.attempts ?? 120;
const delayMs = options.delayMs ?? 250;
const waitStarted = Date.now();
@@ -121,7 +122,7 @@ export async function waitForRuntimeProcess(
probeIdentity: options.probeIdentity ?? defaultProbeIdentity,
requireServiceReceipt: true,
});
if (attachment !== null) return;
if (attachment !== null) return attachment;
} catch (error) {
if (
!(error instanceof RuntimeDiscoveryError) ||
@@ -161,8 +162,117 @@ export async function stopRuntimeProcess(
if (attachment.servicePid === null) {
throw new RuntimeDiscoveryError("runtime_service_receipt_invalid");
}
await terminateVerifiedRuntime(options, attachment.servicePid);
return "stopped";
}
export interface StopAllRuntimesOptions extends StopRuntimeOptions {
/** How long a Runtime caught starting or stopping may take to become
* verifiable before the stop gives up on it. */
settleMs?: number;
/** Upper bound on Runtimes stopped in one call. The lease admits one at a
* time, so more than a few means something keeps starting new ones. */
maxRuntimes?: number;
}
export interface StopAllRuntimesResult {
/** Verified Runtime processes this call stopped. */
stopped: number[];
/** A live pid the Runtime lease names that could not be verified as a
* MemHub Runtime of this data dir. Never signalled: a recycled pid belongs
* to someone else. */
unverified: number[];
}
/** Stop every Runtime serving `dataDir`, whoever started it.
*
* The Runtime lease (`runtime-service.lock`) admits one Runtime per data dir,
* so "every" is the current leaseholder plus any successor that takes the
* lease while this runs: the App's own transient child, a `memhub start`, a
* leftover from an earlier session. Each is found through its receipt and
* signalled only once the authenticated identity endpoint has proved that the
* receipt's pid is the Runtime serving this data dir. A live lease owner that
* cannot be proved that way is reported, never signalled.
*/
export async function stopAllRuntimeProcesses(
options: StopAllRuntimesOptions,
): Promise<StopAllRuntimesResult> {
const alive = options.processIsAlive ?? processIsAlive;
const stopped: number[] = [];
const settleDeadline = Date.now() + (options.settleMs ?? 2_000);
const maxRuntimes = options.maxRuntimes ?? 3;
while (stopped.length < maxRuntimes) {
let attachment: RuntimeAttachment | null;
try {
attachment = await discoverRuntimeAttachment({
dataDir: options.dataDir,
processIsAlive: alive,
probeIdentity: options.probeIdentity ?? defaultProbeIdentity,
requireServiceReceipt: true,
});
} catch (error) {
if (settling(error) && Date.now() < settleDeadline) {
await delay(100);
continue;
}
throw error;
}
if (attachment === null) break;
if (attachment.servicePid === null) {
throw new RuntimeDiscoveryError("runtime_service_receipt_invalid");
}
await terminateVerifiedRuntime(options, attachment.servicePid);
stopped.push(attachment.servicePid);
}
const leasePid = await readRuntimeLeaseOwnerPid(options.dataDir);
const unverified =
leasePid !== null &&
leasePid !== process.pid &&
!stopped.includes(leasePid) &&
alive(leasePid)
? [leasePid]
: [];
return { stopped, unverified };
}
/** The pid the Runtime lease names, or null without a readable lease. */
export async function readRuntimeLeaseOwnerPid(
dataDir: string,
): Promise<number | null> {
try {
const owner = JSON.parse(
await readFile(
path.join(dataDir, "runtime-service.lock", "owner.json"),
"utf8",
),
) as { pid?: unknown };
return typeof owner.pid === "number" &&
Number.isInteger(owner.pid) &&
owner.pid > 1
? owner.pid
: null;
} catch {
return null;
}
}
/** A Runtime between states: starting, stopping, or swapping receipts. */
function settling(error: unknown): boolean {
return (
error instanceof RuntimeDiscoveryError &&
(error.code === "runtime_service_not_ready" ||
error.code === "runtime_service_receipt_invalid" ||
error.code === "runtime_identity_unavailable")
);
}
async function terminateVerifiedRuntime(
options: StopRuntimeOptions,
pid: number,
): Promise<void> {
const alive = options.processIsAlive ?? processIsAlive;
const signal = options.signalProcess ?? process.kill;
signal(attachment.servicePid, "SIGTERM");
signal(pid, "SIGTERM");
const timeoutMs = options.timeoutMs ?? 15_000;
const deadline = Date.now() + timeoutMs;
// The process exiting is the authoritative stop signal. The receipt files
@@ -171,10 +281,10 @@ export async function stopRuntimeProcess(
const receiptGraceMs = Math.min(RECEIPT_CLEANUP_GRACE_MS, timeoutMs);
let exitedAt: number | null = null;
while (Date.now() < deadline) {
if (!alive(attachment.servicePid)) {
if (!alive(pid)) {
exitedAt ??= Date.now();
if (await receiptsAreGone(options.dataDir)) return "stopped";
if (Date.now() - exitedAt >= receiptGraceMs) return "stopped";
if (await receiptsAreGone(options.dataDir)) return;
if (Date.now() - exitedAt >= receiptGraceMs) return;
} else {
exitedAt = null;
}
@@ -73,6 +73,23 @@ async function lockEntries(dataDir: string): Promise<string[]> {
}
describe("RuntimeService", () => {
it("publishes its build so an App can compare it before adopting", async () => {
const dataDir = await directory("runtime-service-build-");
const service = new RuntimeService({
dataDir,
createSupervisor: () => supervisor(),
pid: 4321,
buildIdentity: { buildNumber: 38, commit: null },
});
await service.start();
const receipt = JSON.parse(
await readFile(path.join(dataDir, "runtime-service.json"), "utf8"),
);
expect(receipt).toMatchObject({ build_number: 38, git_commit: null });
await service.stop();
});
it("publishes and removes an outer Runtime receipt without secrets", async () => {
const dataDir = await directory("runtime-service-");
const runtime = supervisor();
@@ -46,6 +46,16 @@ export interface RuntimeServiceOptions {
* owner.json; see removeUnownedLease. Injectable for tests. */
legacyBootTime?: () => number | null;
diagnostics?: DiagnosticSink;
/** Build this process runs, published in the receipt so an App can tell an
* older or newer Runtime from its own before adopting it. `null` fields mean
* "this Runtime does not know"; a receipt without the fields at all was
* written by a Runtime older than this contract. */
buildIdentity?: RuntimeBuildIdentity;
}
export interface RuntimeBuildIdentity {
buildNumber: number | null;
commit: string | null;
}
interface LeaseIdentity {
@@ -406,6 +416,12 @@ export class RuntimeService {
pid: this.pid(),
core_port: Number(url.port),
...(sidecar === undefined ? {} : { sidecar }),
...(this.options.buildIdentity === undefined
? {}
: {
build_number: this.options.buildIdentity.buildNumber,
git_commit: this.options.buildIdentity.commit,
}),
started_at: new Date().toISOString(),
},
null,
@@ -0,0 +1,250 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
stopAllRuntimeProcesses,
waitForRuntimeProcess,
} from "./runtime-launcher.js";
async function writeRuntime(
dataDir: string,
runtime: {
instance: string;
servicePid: number;
corePid: number;
build?: Record<string, unknown>;
},
) {
await mkdir(dataDir, { recursive: true });
await writeFile(
path.join(dataDir, "runtime.json"),
JSON.stringify({
schema_version: 1,
service: "one-memory-core",
instance_id: runtime.instance,
pid: runtime.corePid,
parent_pid: runtime.servicePid,
host: "127.0.0.1",
port: 8765,
started_at: "now",
}),
);
await writeFile(path.join(dataDir, "runtime-token"), "token", {
mode: 0o600,
});
await writeFile(
path.join(dataDir, "runtime-service.json"),
JSON.stringify({
schema_version: 1,
service: "one-memory-runtime",
status: "running",
instance_id: runtime.instance,
pid: runtime.servicePid,
core_port: 8765,
...runtime.build,
started_at: "now",
}),
{ mode: 0o600 },
);
}
async function writeLease(dataDir: string, pid: number) {
await mkdir(path.join(dataDir, "runtime-service.lock"), { recursive: true });
await writeFile(
path.join(dataDir, "runtime-service.lock", "owner.json"),
JSON.stringify({ pid, created_at: "now", boot_id: null }),
);
}
async function removeRuntime(dataDir: string) {
for (const name of ["runtime.json", "runtime-service.json", "runtime-token"])
await rm(path.join(dataDir, name), { force: true });
await rm(path.join(dataDir, "runtime-service.lock"), {
recursive: true,
force: true,
});
}
const identityFor = (live: Map<number, string>) =>
async function probeIdentity() {
// The identity endpoint answers for whichever Runtime holds the receipts.
const instance = [...live.values()].at(-1) ?? "none";
return {
status: "ok" as const,
service: "one-memory-core" as const,
version: "1.0.0",
instance_id: instance,
};
};
describe("stopping every Runtime of a data dir", () => {
it("stops the App's own child and an externally started successor", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "runtime-stop-all-"));
// pid -> instance; core pids are always alive while their service is.
const live = new Map<number, string>([[4321, "app-child"]]);
await writeRuntime(dataDir, {
instance: "app-child",
servicePid: 4321,
corePid: 1234,
});
await writeLease(dataDir, 4321);
const signalProcess = vi.fn(async (pid: number) => {
live.delete(pid);
await removeRuntime(dataDir);
if (pid === 4321) {
// `memhub start` was waiting on the lease and takes over at once.
live.set(5555, "cli-started");
await writeRuntime(dataDir, {
instance: "cli-started",
servicePid: 5555,
corePid: 5556,
});
await writeLease(dataDir, 5555);
}
});
const result = await stopAllRuntimeProcesses({
dataDir,
timeoutMs: 2_000,
processIsAlive: (pid) =>
live.has(pid) ||
(pid === 1234 && live.has(4321)) ||
(pid === 5556 && live.has(5555)),
signalProcess: (pid) => void signalProcess(pid),
probeIdentity: identityFor(live),
});
expect(result).toEqual({ stopped: [4321, 5555], unverified: [] });
expect(signalProcess.mock.calls.map(([pid]) => pid)).toEqual([4321, 5555]);
});
it("never signals a live lease owner it cannot verify as a MemHub Runtime", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "runtime-stop-all-"));
// A lease left by a crashed Runtime whose pid the OS gave to another
// program: no receipt, no identity endpoint, just a live number.
await writeLease(dataDir, 7777);
const signalProcess = vi.fn();
const result = await stopAllRuntimeProcesses({
dataDir,
processIsAlive: (pid) => pid === 7777,
signalProcess,
probeIdentity: async () => {
throw new Error("no endpoint");
},
});
expect(result).toEqual({ stopped: [], unverified: [7777] });
expect(signalProcess).not.toHaveBeenCalled();
});
it("refuses to signal a receipt pid whose identity does not match", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "runtime-stop-all-"));
await writeRuntime(dataDir, {
instance: "runtime",
servicePid: 4321,
corePid: 1234,
});
const signalProcess = vi.fn();
await expect(
stopAllRuntimeProcesses({
dataDir,
processIsAlive: () => true,
signalProcess,
probeIdentity: async () => ({
status: "ok",
service: "one-memory-core",
version: "1.0.0",
instance_id: "someone-else",
}),
}),
).rejects.toThrow("runtime_identity_mismatch");
expect(signalProcess).not.toHaveBeenCalled();
});
it("reports nothing to stop for an empty data dir", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "runtime-stop-all-"));
await expect(
stopAllRuntimeProcesses({
dataDir,
processIsAlive: () => false,
signalProcess: vi.fn(),
}),
).resolves.toEqual({ stopped: [], unverified: [] });
});
});
describe("the build a running Runtime publishes", () => {
const probeIdentity = async () => ({
status: "ok" as const,
service: "one-memory-core" as const,
version: "1.0.0",
instance_id: "runtime",
});
it("is read from its receipt", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "runtime-build-"));
await writeRuntime(dataDir, {
instance: "runtime",
servicePid: 4321,
corePid: 1234,
build: { build_number: 38, git_commit: "abcdef1" },
});
const attachment = await waitForRuntimeProcess({
dataDir,
attempts: 1,
processIsAlive: () => true,
probeIdentity,
});
expect(attachment.servicePid).toBe(4321);
expect(attachment.runtimeBuild).toEqual({
buildNumber: 38,
commit: "abcdef1",
});
});
it("is absent for a receipt from a Runtime older than the field", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "runtime-build-"));
await writeRuntime(dataDir, {
instance: "runtime",
servicePid: 4321,
corePid: 1234,
});
const attachment = await waitForRuntimeProcess({
dataDir,
attempts: 1,
processIsAlive: () => true,
probeIdentity,
});
expect(attachment.runtimeBuild).toBeUndefined();
});
it("reads a malformed build as unknown, not as a broken receipt", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "runtime-build-"));
await writeRuntime(dataDir, {
instance: "runtime",
servicePid: 4321,
corePid: 1234,
build: { build_number: "38", git_commit: "not a commit" },
});
const attachment = await waitForRuntimeProcess({
dataDir,
attempts: 1,
processIsAlive: () => true,
probeIdentity,
});
expect(attachment.runtimeBuild).toEqual({
buildNumber: null,
commit: null,
});
});
});
@@ -0,0 +1,339 @@
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
parseLaunchctlPid,
queryRuntimeServiceState,
type ServiceStateProbe,
} from "./service-state.js";
const LABEL = "com.tencent.onememory.runtime";
async function home() {
const root = await mkdtemp(path.join(tmpdir(), "service-state-"));
const dataDir = path.join(root, ".one-memory");
await mkdir(dataDir, { recursive: true });
return { root, dataDir };
}
async function writeLaunchAgent(root: string, dataDir: string, label = LABEL) {
const directory = path.join(root, "Library", "LaunchAgents");
await mkdir(directory, { recursive: true });
await writeFile(
path.join(directory, `${label}.plist`),
`<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0"><dict>
<key>Label</key><string>${label}</string>
<key>EnvironmentVariables</key><dict><key>ONE_MEMORY_DATA_DIR</key><string>${dataDir.replaceAll("&", "&amp;")}</string></dict>
</dict></plist>
`,
);
}
function launchctl(options: {
disabled?: boolean;
job?: { code: number; pid?: number };
overridesCode?: number;
}): ServiceStateProbe {
return vi.fn(async (command: readonly string[]) => {
if (command[1] === "print-disabled") {
return {
code: options.overridesCode ?? 0,
stdout: `disabled services = {\n\t"${LABEL}" => ${options.disabled ? "disabled" : "enabled"}\n}\n`,
};
}
const job = options.job ?? { code: 0, pid: 4321 };
return {
code: job.code,
stdout:
job.code === 0
? `${LABEL} = {\n\tpath = /x.plist\n${job.pid ? `\tpid = ${job.pid}\n` : ""}\tendpoints = {\n\t\tpid = 1\n\t}\n}\n`
: "",
};
});
}
describe("asking launchd whether the background service is on", () => {
it("is enabled for an installed, loaded job with its enable marker", async () => {
const { root, dataDir } = await home();
await writeLaunchAgent(root, dataDir);
await expect(
queryRuntimeServiceState({
dataDir,
home: root,
platform: "darwin",
uid: 501,
probe: launchctl({}),
markerEnabled: async () => true,
}),
).resolves.toEqual({
state: "enabled",
reason: "service-enabled",
jobPid: 4321,
});
});
it("is disabled when no LaunchAgent serves this data dir", async () => {
const { root, dataDir } = await home();
const probe = launchctl({});
await expect(
queryRuntimeServiceState({
dataDir,
home: root,
platform: "darwin",
uid: 501,
probe,
markerEnabled: async () => true,
}),
).resolves.toMatchObject({
state: "disabled",
reason: "service-not-installed",
});
expect(probe).not.toHaveBeenCalled();
});
it("ignores a LaunchAgent that serves another data dir", async () => {
const { root, dataDir } = await home();
await writeLaunchAgent(root, path.join(root, "other-data"));
await expect(
queryRuntimeServiceState({
dataDir,
home: root,
platform: "darwin",
uid: 501,
probe: launchctl({}),
markerEnabled: async () => true,
}),
).resolves.toMatchObject({
state: "disabled",
reason: "service-not-installed",
});
});
it("finds an isolated install root's scoped label", async () => {
const { root, dataDir } = await home();
await writeLaunchAgent(root, dataDir, `${LABEL}.0123456789abcdef`);
const probe: ServiceStateProbe = async (command) =>
command[1] === "print-disabled"
? { code: 0, stdout: "" }
: command[2] === `gui/501/${LABEL}.0123456789abcdef`
? { code: 0, stdout: "\tpid = 999\n" }
: { code: 113, stdout: "" };
await expect(
queryRuntimeServiceState({
dataDir,
home: root,
platform: "darwin",
uid: 501,
probe,
markerEnabled: async () => true,
}),
).resolves.toEqual({
state: "enabled",
reason: "service-enabled",
jobPid: 999,
});
});
it("is disabled under a launchctl disable override (the transient fallback)", async () => {
const { root, dataDir } = await home();
await writeLaunchAgent(root, dataDir);
await expect(
queryRuntimeServiceState({
dataDir,
home: root,
platform: "darwin",
uid: 501,
probe: launchctl({ disabled: true, job: { code: 113 } }),
markerEnabled: async () => true,
}),
).resolves.toEqual({
state: "disabled",
reason: "service-disabled-by-launchctl",
jobPid: null,
});
});
it("is disabled when Launch at login is off, even with the job still loaded", async () => {
const { root, dataDir } = await home();
await writeLaunchAgent(root, dataDir);
await expect(
queryRuntimeServiceState({
dataDir,
home: root,
platform: "darwin",
uid: 501,
probe: launchctl({}),
markerEnabled: async () => false,
}),
).resolves.toEqual({
state: "disabled",
reason: "service-enable-marker-absent",
jobPid: 4321,
});
});
it("stays enabled for an installed job launchd has not loaded yet", async () => {
const { root, dataDir } = await home();
await writeLaunchAgent(root, dataDir);
await expect(
queryRuntimeServiceState({
dataDir,
home: root,
platform: "darwin",
uid: 501,
probe: launchctl({ job: { code: 113 } }),
markerEnabled: async () => true,
}),
).resolves.toEqual({
state: "enabled",
reason: "service-enabled",
jobPid: null,
});
});
it.each([
["the override table cannot be read", launchctl({ overridesCode: 1 })],
["the job query fails", launchctl({ job: { code: 5 } })],
[
"launchctl cannot run",
(async () => {
throw new Error("spawn failed");
}) as ServiceStateProbe,
],
])("is unknown when %s", async (_case, probe) => {
const { root, dataDir } = await home();
await writeLaunchAgent(root, dataDir);
await expect(
queryRuntimeServiceState({
dataDir,
home: root,
platform: "darwin",
uid: 501,
probe,
markerEnabled: async () => true,
}),
).resolves.toMatchObject({ state: "unknown" });
});
it("is unknown when the query outlives its budget", async () => {
const { root, dataDir } = await home();
await writeLaunchAgent(root, dataDir);
await expect(
queryRuntimeServiceState({
dataDir,
home: root,
platform: "darwin",
uid: 501,
timeoutMs: 20,
probe: () => new Promise(() => {}),
markerEnabled: async () => true,
}),
).resolves.toEqual({
state: "unknown",
reason: "service-state-timeout",
jobPid: null,
});
});
it("is unknown without a user id to name the launchd domain", async () => {
const { root, dataDir } = await home();
await writeLaunchAgent(root, dataDir);
await expect(
queryRuntimeServiceState({
dataDir,
home: root,
platform: "darwin",
probe: launchctl({}),
markerEnabled: async () => true,
}),
).resolves.toMatchObject({ state: "unknown" });
});
});
describe("asking systemd whether the background service is on", () => {
async function writeUnit(root: string, dataDir: string) {
const directory = path.join(root, ".config", "systemd", "user");
await mkdir(directory, { recursive: true });
await writeFile(
path.join(directory, "one-memory.service"),
`[Service]\nEnvironment="ONE_MEMORY_DATA_DIR=${dataDir}"\n`,
);
}
const systemctl =
(answer: string, code = 0): ServiceStateProbe =>
async (command) =>
command.includes("is-enabled")
? { code, stdout: `${answer}\n` }
: { code: 0, stdout: "2468\n" };
it("follows systemctl is-enabled", async () => {
const { root, dataDir } = await home();
await writeUnit(root, dataDir);
await expect(
queryRuntimeServiceState({
dataDir,
home: root,
platform: "linux",
probe: systemctl("enabled"),
}),
).resolves.toEqual({
state: "enabled",
reason: "service-enabled",
jobPid: 2468,
});
await expect(
queryRuntimeServiceState({
dataDir,
home: root,
platform: "linux",
probe: systemctl("disabled", 1),
}),
).resolves.toMatchObject({
state: "disabled",
reason: "service-disabled-by-systemd",
});
});
it("is disabled without a unit and unknown without an answer", async () => {
const { root, dataDir } = await home();
await expect(
queryRuntimeServiceState({
dataDir,
home: root,
platform: "linux",
probe: systemctl("enabled"),
}),
).resolves.toMatchObject({
state: "disabled",
reason: "service-not-installed",
});
await writeUnit(root, dataDir);
await expect(
queryRuntimeServiceState({
dataDir,
home: root,
platform: "linux",
probe: systemctl("", 1),
}),
).resolves.toMatchObject({ state: "unknown" });
});
});
it("reads only the job's own top-level pid", () => {
expect(parseLaunchctlPid("x = {\n\tpid = 77\n\tendpoints = {\n}\n")).toBe(77);
expect(parseLaunchctlPid("x = {\n\tstate = waiting\n}\n")).toBeNull();
});
@@ -0,0 +1,317 @@
import { spawn } from "node:child_process";
import { readdir, readFile, realpath } from "node:fs/promises";
import path from "node:path";
import { parseDisabledOverride } from "./service-install.js";
import { runtimeServiceEnabled } from "./service-ownership.js";
import { RUNTIME_SERVICE_ID } from "./service-plan.js";
/**
* Whether the background service would keep a Runtime for this data dir alive
* without the App.
*
* `enabled`: a LaunchAgent (or systemd user unit) declaring this data dir is
* installed, the OS has no disable override for it, and on macOS the enable
* marker its `KeepAlive` waits on is present. The marker is the App's "Launch
* at login" switch and `memhub service disable`: without it launchd neither
* starts nor restarts the job, so the service is off even while the plist is
* still installed.
*
* `disabled`: none of that holds. The service was never installed or was
* uninstalled, `launchctl disable` / `systemctl disable` turned it off (the
* case in which the App falls back to a transient Runtime), or the enable
* marker is gone.
*
* `unknown`: a query failed or timed out. Callers must not act on it.
*/
export type RuntimeServiceStateName = "enabled" | "disabled" | "unknown";
export type RuntimeServiceStateReason =
| "service-enabled"
| "service-not-installed"
| "service-enable-marker-absent"
| "service-disabled-by-launchctl"
| "service-disabled-by-systemd"
| "service-unsupported-platform"
| "service-state-query-failed"
| "service-state-timeout";
export interface RuntimeServiceState {
state: RuntimeServiceStateName;
reason: RuntimeServiceStateReason;
/** The pid the service manager reports for the running job: null when the
* job is not loaded, not running, or the state is unknown. */
jobPid: number | null;
}
export type ServiceStateProbe = (
command: readonly string[],
timeoutMs: number,
) => Promise<{ code: number | null; stdout: string }>;
export interface RuntimeServiceStateOptions {
dataDir: string;
home: string;
platform?: NodeJS.Platform;
uid?: number;
xdgConfigHome?: string;
/** Budget for the whole query; past it the state is `unknown`. */
timeoutMs?: number;
probe?: ServiceStateProbe;
markerEnabled?: (dataDir: string) => Promise<boolean>;
}
const LAUNCH_AGENT_FILE = new RegExp(
`^${RUNTIME_SERVICE_ID.replaceAll(".", "\\.")}(?:\\.[0-9a-f]{16})?\\.plist$`,
);
/** Ask the service manager, now, whether the background service is on.
*
* Never throws and never caches: every answer is a fresh `launchctl` /
* `systemctl` read, and any failure or timeout is `unknown`.
*/
export async function queryRuntimeServiceState(
options: RuntimeServiceStateOptions,
): Promise<RuntimeServiceState> {
const timeoutMs = options.timeoutMs ?? 3_000;
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<RuntimeServiceState>((resolve) => {
timer = setTimeout(
() => resolve(state("unknown", "service-state-timeout")),
timeoutMs,
);
});
try {
return await Promise.race([
inspect(options, timeoutMs).catch(() =>
state("unknown", "service-state-query-failed"),
),
timeout,
]);
} finally {
clearTimeout(timer);
}
}
async function inspect(
options: RuntimeServiceStateOptions,
timeoutMs: number,
): Promise<RuntimeServiceState> {
const platform = options.platform ?? process.platform;
const probe = options.probe ?? runProbe;
if (platform === "darwin")
return inspectLaunchAgent(options, probe, timeoutMs);
if (platform === "linux")
return inspectSystemdUnit(options, probe, timeoutMs);
return state("disabled", "service-unsupported-platform");
}
async function inspectLaunchAgent(
options: RuntimeServiceStateOptions,
probe: ServiceStateProbe,
timeoutMs: number,
): Promise<RuntimeServiceState> {
const uid = options.uid;
if (uid === undefined || !Number.isSafeInteger(uid) || uid < 1) {
return state("unknown", "service-state-query-failed");
}
const labels = await launchAgentsServing(options.home, options.dataDir);
if (labels.length === 0) return state("disabled", "service-not-installed");
const domain = `gui/${uid}`;
const overrides = await probe(
["launchctl", "print-disabled", domain],
timeoutMs,
);
if (overrides.code !== 0) {
return state("unknown", "service-state-query-failed");
}
const marker = await (options.markerEnabled ?? runtimeServiceEnabled)(
options.dataDir,
);
let first: RuntimeServiceState | null = null;
for (const label of labels) {
const job = await probe(
["launchctl", "print", `${domain}/${label}`],
timeoutMs,
);
// 113: the label is not loaded. Anything else unexpected is unknown.
if (job.code !== 0 && job.code !== 113) {
return state("unknown", "service-state-query-failed");
}
const jobPid = job.code === 0 ? parseLaunchctlPid(job.stdout) : null;
const current = parseDisabledOverride(overrides.stdout, label)
? state("disabled", "service-disabled-by-launchctl", jobPid)
: marker
? state("enabled", "service-enabled", jobPid)
: state("disabled", "service-enable-marker-absent", jobPid);
if (current.state === "enabled") return current;
first ??= current;
}
return first ?? state("disabled", "service-not-installed");
}
/** Labels of the MemHub LaunchAgents whose declaration serves `dataDir`.
* The daily install and isolated install roots use different labels, and a
* LaunchAgent for another data dir is not this data dir's service. */
async function launchAgentsServing(
home: string,
dataDir: string,
): Promise<string[]> {
const directory = path.join(home, "Library", "LaunchAgents");
let names: string[];
try {
names = await readdir(directory);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
throw error;
}
const wanted = await canonical(dataDir);
const labels: string[] = [];
for (const name of names
.filter((item) => LAUNCH_AGENT_FILE.test(item))
.sort()) {
const label = name.slice(0, -".plist".length);
const content = await readFile(path.join(directory, name), "utf8");
if (!content.includes(`<key>Label</key><string>${label}</string>`))
continue;
const declared =
/<key>ONE_MEMORY_DATA_DIR<\/key><string>([^<]*)<\/string>/.exec(
content,
)?.[1];
if (declared === undefined) continue;
if ((await canonical(unescapeXml(declared))) === wanted) labels.push(label);
}
return labels;
}
async function inspectSystemdUnit(
options: RuntimeServiceStateOptions,
probe: ServiceStateProbe,
timeoutMs: number,
): Promise<RuntimeServiceState> {
const config = path.resolve(
options.xdgConfigHome?.trim() || path.join(options.home, ".config"),
);
let content: string;
try {
content = await readFile(
path.join(config, "systemd", "user", "one-memory.service"),
"utf8",
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return state("disabled", "service-not-installed");
}
throw error;
}
const declared =
/^Environment="ONE_MEMORY_DATA_DIR=((?:[^"\\]|\\.)*)"$/m.exec(content)?.[1];
if (
declared === undefined ||
(await canonical(declared.replace(/\\(.)/g, "$1"))) !==
(await canonical(options.dataDir))
) {
return state("disabled", "service-not-installed");
}
const enabled = await probe(
["systemctl", "--user", "is-enabled", "one-memory.service"],
timeoutMs,
);
const answer = enabled.stdout.trim().split(/\r?\n/)[0] ?? "";
if (enabled.code === null || answer === "") {
return state("unknown", "service-state-query-failed");
}
const main = await probe(
[
"systemctl",
"--user",
"show",
"one-memory.service",
"--property=MainPID",
"--value",
],
timeoutMs,
);
const pid = Number(main.stdout.trim());
const jobPid =
main.code === 0 && Number.isSafeInteger(pid) && pid > 1 ? pid : null;
if (answer === "enabled" || answer === "enabled-runtime") {
return state("enabled", "service-enabled", jobPid);
}
return state("disabled", "service-disabled-by-systemd", jobPid);
}
/** The top-level `pid = N` launchctl prints for a running job. Top-level
* properties are indented by exactly one tab; nested blocks (endpoints,
* spawn info) carry their own `pid` lines and must not be read as the job's. */
export function parseLaunchctlPid(stdout: string): number | null {
for (const line of stdout.split(/\r?\n/)) {
const match = /^\tpid = (\d+)\s*$/.exec(line);
if (!match) continue;
const pid = Number(match[1]);
return Number.isSafeInteger(pid) && pid > 1 ? pid : null;
}
return null;
}
function state(
name: RuntimeServiceStateName,
reason: RuntimeServiceStateReason,
jobPid: number | null = null,
): RuntimeServiceState {
return { state: name, reason, jobPid };
}
async function canonical(target: string): Promise<string> {
const resolved = path.resolve(target);
return realpath(resolved).catch(() => resolved);
}
function unescapeXml(value: string): string {
return value
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">")
.replaceAll("&quot;", '"')
.replaceAll("&apos;", "'")
.replaceAll("&amp;", "&");
}
async function runProbe(
command: readonly string[],
timeoutMs: number,
): Promise<{ code: number | null; stdout: string }> {
const [program, ...args] = command;
if (!program) throw new Error("runtime_service_inspection_failed");
return new Promise((resolve, reject) => {
const child = spawn(program, args, {
shell: false,
windowsHide: true,
stdio: ["ignore", "pipe", "ignore"],
});
const timer = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error("runtime_service_inspection_timeout"));
}, timeoutMs);
const chunks: Buffer[] = [];
let size = 0;
child.stdout.on("data", (chunk: Buffer) => {
size += chunk.length;
if (size > 256 * 1024) {
child.kill();
return;
}
chunks.push(chunk);
});
child.once("error", () => {
clearTimeout(timer);
reject(new Error("runtime_service_inspection_failed"));
});
child.once("close", (code) => {
clearTimeout(timer);
if (size > 256 * 1024) {
reject(new Error("runtime_service_inspection_failed"));
return;
}
resolve({ code, stdout: Buffer.concat(chunks).toString("utf8") });
});
});
}
+346
View File
@@ -0,0 +1,346 @@
# -*- coding: utf-8 -*-
"""
Intention(L7)读侧判重开关(issue #241)。
覆盖:
1. 默认开启:行为不变,复本只返回最早一条,其余异步删库;
2. 关闭:不隐藏、不删除,两条意图都返回;
3. 阈值生效:cosine 低于阈值不判重,高于阈值判重;
4. reader 按 client 的 MemoryConfig.recall 读取开关/阈值,而非调用时的 env;
5. MemoryConfig 默认值与 env 回退;
6. 写入侧批内判重同样受开关控制;阈值不影响写入侧;
7. 关闭时不为判重补拉 embedding;配置阈值非法时不影响召回。
"""
from __future__ import annotations
import uuid
from datetime import datetime
import pytest
from hy_memory.config import MemoryConfig
from hy_memory.pipelines.base import ReadRequest
from hy_memory.pipelines._retrieval.intention import recall_intentions
from tests.test_intention_dedup import (
_FakeVectorStore,
_make_intention_node,
_run,
)
_MA = datetime(2025, 8, 6)
_VU = datetime(2025, 8, 31, 23, 59)
_NOW = datetime(2025, 8, 6, 12, 0)
def _unit(vec):
n = sum(x * x for x in vec) ** 0.5 or 1.0
return [x / n for x in vec]
def _seed_project_pair(vs, *, emb_a, emb_b):
"""A / B 两个项目的近似意图:同 user/agent/同天/同截止日。A 更早写入。"""
a = _make_intention_node(
"A 项目每周一触发发布前校验流程", memory_at=_MA, valid_until=_VU,
gmt_created=datetime(2025, 8, 1), embedding=emb_a,
)
b = _make_intention_node(
"B 项目每周一触发发布前校验流程", memory_at=_MA, valid_until=_VU,
gmt_created=datetime(2025, 8, 2), embedding=emb_b,
)
_run(vs.upsert(a))
_run(vs.upsert(b))
return a, b
# ============================================================================
# recall_intentions 参数
# ============================================================================
def test_default_on_keeps_earliest_and_deletes_duplicate():
vs = _FakeVectorStore()
emb = _unit([1.0, 0.0])
a, b = _seed_project_pair(vs, emb_a=emb, emb_b=list(emb))
stats = {}
result = _run(recall_intentions(
vs, emb, user_ids=["u1"], limit=10, now=_NOW, stats_out=stats,
))
assert [h["node_id"] for h in result] == [a.node_id]
assert vs.delete_calls == [b.node_id]
assert stats["dedup_enabled"] is True
assert stats["dedup_dropped"] == 1
def test_off_returns_both_and_deletes_nothing():
vs = _FakeVectorStore()
emb = _unit([1.0, 0.0])
a, b = _seed_project_pair(vs, emb_a=emb, emb_b=list(emb))
stats = {}
result = _run(recall_intentions(
vs, emb, user_ids=["u1"], limit=10, now=_NOW, stats_out=stats,
dedup_enabled=False,
))
assert {h["node_id"] for h in result} == {a.node_id, b.node_id}
assert vs.delete_calls == []
assert stats["dedup_enabled"] is False
assert stats["dedup_dropped"] == 0
def test_threshold_respected():
# cosine(a, b) = 0.9
emb_a = _unit([1.0, 0.0])
emb_b = _unit([0.9, (1 - 0.81) ** 0.5])
# 默认 0.95 → 0.9 不算复本
vs = _FakeVectorStore()
_seed_project_pair(vs, emb_a=emb_a, emb_b=emb_b)
result = _run(recall_intentions(
vs, emb_a, user_ids=["u1"], limit=10, now=_NOW, dedup_threshold=0.95,
))
assert len(result) == 2
assert vs.delete_calls == []
# 调低到 0.85 → 判重,删较晚的 B
vs = _FakeVectorStore()
a, b = _seed_project_pair(vs, emb_a=emb_a, emb_b=emb_b)
stats = {}
result = _run(recall_intentions(
vs, emb_a, user_ids=["u1"], limit=10, now=_NOW,
dedup_threshold=0.85, stats_out=stats,
))
assert [h["node_id"] for h in result] == [a.node_id]
assert vs.delete_calls == [b.node_id]
assert stats["dedup_threshold"] == 0.85
def test_explicit_threshold_wins_over_env(monkeypatch):
monkeypatch.setenv("MEMORY_DEDUP_THRESHOLD", "0.5")
emb_a = _unit([1.0, 0.0])
emb_b = _unit([0.9, (1 - 0.81) ** 0.5])
vs = _FakeVectorStore()
_seed_project_pair(vs, emb_a=emb_a, emb_b=emb_b)
result = _run(recall_intentions(
vs, emb_a, user_ids=["u1"], limit=10, now=_NOW, dedup_threshold=0.95,
))
assert len(result) == 2
assert vs.delete_calls == []
def test_off_skips_embedding_backfill():
vs = _FakeVectorStore()
emb = _unit([1.0, 0.0])
_seed_project_pair(vs, emb_a=emb, emb_b=list(emb))
backfill_calls = []
async def _get_embeddings(ids):
backfill_calls.append(ids)
return {}
vs.get_embeddings = _get_embeddings
for n in vs._nodes.values():
n.embedding = None
result = _run(recall_intentions(
vs, emb, user_ids=["u1"], limit=10, now=_NOW, dedup_enabled=False,
))
assert len(result) == 2
assert backfill_calls == []
def test_invalid_threshold_degrades_to_env_default(monkeypatch):
monkeypatch.delenv("MEMORY_DEDUP_THRESHOLD", raising=False)
vs = _FakeVectorStore()
emb = _unit([1.0, 0.0])
a, b = _seed_project_pair(vs, emb_a=emb, emb_b=list(emb))
stats = {}
result = _run(recall_intentions(
vs, emb, user_ids=["u1"], limit=10, now=_NOW, stats_out=stats,
dedup_threshold="not-a-number",
))
assert [h["node_id"] for h in result] == [a.node_id]
assert stats["dedup_threshold"] == 0.95
# ============================================================================
# 写入侧批内判重
# ============================================================================
class _SameVectorEmbed:
"""所有文本同一向量:cosine=1,开启判重时必判为复本。"""
async def embed_batch(self, texts):
return [_unit([1.0, 0.0]) for _ in texts]
async def embed_queued(self, text):
return _unit([1.0, 0.0])
def _store_batch(config_recall):
from hy_memory.pipelines.base import WriteRequest
from hy_memory.pipelines.writer import MemoryWriter
cfg = MemoryConfig.from_dict({"recall": config_recall})
vs = _FakeVectorStore()
writer = MemoryWriter(cfg, embed_service=_SameVectorEmbed(), vector_store=vs)
req = WriteRequest(
content="", messages=[], user_id="u1", agent_id="a1",
session_id="s1", request_id=uuid.uuid4().hex,
)
intentions = [
{"content": "A 项目每周一触发发布前校验流程", "tags": [], "valid_until": None},
{"content": "B 项目每周一触发发布前校验流程", "tags": [], "valid_until": None},
]
return _run(writer._store_intentions(intentions, req, vs, req.request_id)), vs
def test_write_batch_dedup_default_on_unchanged():
ids, vs = _store_batch({})
assert len(ids) == 1
assert [n.content for n in vs._nodes.values()] == [
"A 项目每周一触发发布前校验流程",
]
def test_write_batch_dedup_off_stores_both():
ids, vs = _store_batch({"intention_dedup_enabled": False})
assert len(ids) == 2
assert len(vs._nodes) == 2
def test_read_threshold_does_not_change_write_batch_dedup(monkeypatch):
"""intention_dedup_threshold 只管读侧;写入侧仍取 MEMORY_DEDUP_THRESHOLD。"""
monkeypatch.delenv("MEMORY_DEDUP_THRESHOLD", raising=False)
ids, _ = _store_batch({"intention_dedup_threshold": 1.5})
assert len(ids) == 1
# ============================================================================
# MemoryConfig
# ============================================================================
def test_config_defaults(monkeypatch):
for k in (
"MEMORY_INTENTION_DEDUP_ENABLED",
"MEMORY_INTENTION_DEDUP_THRESHOLD",
"MEMORY_DEDUP_THRESHOLD",
):
monkeypatch.delenv(k, raising=False)
cfg = MemoryConfig()
assert cfg.recall.intention_dedup_enabled is True
assert cfg.recall.intention_dedup_threshold == 0.95
def test_config_env_fallbacks(monkeypatch):
monkeypatch.setenv("MEMORY_INTENTION_DEDUP_ENABLED", "false")
monkeypatch.setenv("MEMORY_DEDUP_THRESHOLD", "0.9")
monkeypatch.delenv("MEMORY_INTENTION_DEDUP_THRESHOLD", raising=False)
cfg = MemoryConfig()
assert cfg.recall.intention_dedup_enabled is False
# 未设专用阈值 → 回退共享 MEMORY_DEDUP_THRESHOLD(兼容旧部署)
assert cfg.recall.intention_dedup_threshold == 0.9
monkeypatch.setenv("MEMORY_INTENTION_DEDUP_THRESHOLD", "0.8")
assert MemoryConfig().recall.intention_dedup_threshold == 0.8
def test_config_from_dict_overrides_env(monkeypatch):
monkeypatch.setenv("MEMORY_INTENTION_DEDUP_ENABLED", "true")
cfg = MemoryConfig.from_dict({"recall": {
"intention_dedup_enabled": False,
"intention_dedup_threshold": 0.99,
}})
assert cfg.recall.intention_dedup_enabled is False
assert cfg.recall.intention_dedup_threshold == 0.99
assert cfg.to_dict()["recall"]["intention_dedup_enabled"] is False
# ============================================================================
# reader:按 client 配置,而不是调用时的 env
# ============================================================================
def _reader_for(enabled: bool, vs):
from hy_memory.pipelines.reader_hybrid import HybridReadPipeline
cfg = MemoryConfig.from_dict({
"vector_store": {
"provider": "chroma",
"collection_name": f"t_intent_sw_{uuid.uuid4().hex[:8]}",
"embedding_dims": 2,
},
"recall": {"intention_dedup_enabled": enabled},
})
reader = HybridReadPipeline(cfg, vector_store=vs)
reader._vector_store = vs
reader._vector_store_initialized = True
return reader
async def _no_keyword_hits(**kw):
return []
def _read_project_pair(enabled: bool):
"""经 reader 完整读路径召回 A/B 两条近似意图;返回 (返回的意图 id, 删库调用, a, b)。"""
vs = _FakeVectorStore()
vs.keyword_search = _no_keyword_hits
emb = _unit([1.0, 0.0])
a, b = _seed_project_pair(vs, emb_a=emb, emb_b=list(emb))
# 读路径用真实 now:让两条都未过期且同一天
today = datetime.now()
for n in (a, b):
n.memory_at = today
n.valid_until = None
reader = _reader_for(enabled, vs)
_run(reader.initialize())
resp = _run(reader.read(ReadRequest(
query="发布前校验", query_embedding=emb,
user_ids=["u1"], agent_ids=["a1"], limit=10, intention_limit=5,
)))
ids = {
m["memory_id"] for m in resp.memories
if m.get("layer") == "l7_intention"
}
return ids, list(vs.delete_calls), a, b
@pytest.mark.parametrize("env_value", ["true", "false"])
def test_reader_uses_per_client_config_not_env(monkeypatch, env_value):
"""调用时的 env 与 client 配置无关:两个 client 一开一关,各自生效。"""
monkeypatch.setenv("MEMORY_INTENTION_DEDUP_ENABLED", env_value)
monkeypatch.setenv("MEMORY_DEDUP_THRESHOLD", "0.0")
ids_on, deleted_on, a, b = _read_project_pair(True)
assert ids_on == {a.node_id}
assert deleted_on == [b.node_id]
ids_off, deleted_off, a, b = _read_project_pair(False)
assert ids_off == {a.node_id, b.node_id}
assert deleted_off == []
def test_reader_passes_client_threshold(monkeypatch):
monkeypatch.setenv("MEMORY_DEDUP_THRESHOLD", "0.1")
seen = []
async def _spy(*args, **kwargs):
seen.append(kwargs)
return []
monkeypatch.setattr("hy_memory.pipelines.reader_base.recall_intentions", _spy)
vs = _FakeVectorStore()
vs.keyword_search = _no_keyword_hits
reader = _reader_for(True, vs)
reader.config.recall.intention_dedup_threshold = 0.97
_run(reader.initialize())
_run(reader.read(ReadRequest(
query="发布前校验", query_embedding=_unit([1.0, 0.0]),
user_ids=["u1"], agent_ids=["a1"], limit=10, intention_limit=5,
)))
assert seen and seen[0]["dedup_enabled"] is True
assert seen[0]["dedup_threshold"] == 0.97