Compare commits

...
Author SHA1 Message Date
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 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
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
alvinfeiandClaude Opus 5.5 aa633623da docs: MemHub 1.0.6 news and changelog
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 11:46:06 +08:00
feitianxiang 37d8662b93 Merge pull request #266 from Tencent-Hunyuan/chore/build-37
chore(release): MemHub 1.0.6, build 37
2026-11-02 11:44:12 +08:00
alvinfeiandClaude Opus 5.5 9459a1107b chore(release): MemHub 1.0.6, build 37
Sets every MemHub package, the Core project and its lock entry to 1.0.6
with set-release-version.mjs, and the build number to 37. The release
facts in memhub/README.md and docs/INSTALLATION.md now name v1.0.6
(build 37).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 11:36:55 +08:00
feitianxiang 2c6f269170 Merge pull request #264 from Tencent-Hunyuan/chore/pin-hy-memory-2.0.11
chore(memhub): pin hy-memory 2.0.11 and set the SDK language directly
2026-11-02 11:34:45 +08:00
alvinfeiandClaude Opus 5.5 7a2ff2f4c4 build(memhub): pin hy-memory 2.0.11 and set the SDK language directly
Core pins and locks the published hy-memory 2.0.11 from PyPI, which adds
MemoryConfig.language and the prompt registry. apply_sdk_language now sets
the language directly; the guard that skipped it on an SDK without the
field (and its debug log) is gone, and the tests use the real field. The
version constant, README, architecture notes, SBOM, notices and their tests
follow. CHANGELOG records 2.0.11 as published.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 22:22:32 +08:00
feitianxiang 35128f2c84 Merge pull request #262 from Tencent-Hunyuan/feat/memhub-eleven-languages
feat(memhub): 11 display languages; non-zh/en choices set the SDK prompt language
2026-11-01 22:17:21 +08:00
feitianxiang f12a95ce7a Merge pull request #263 from Tencent-Hunyuan/release/hy-memory-2.0.11
release: hy-memory 2.0.11
2026-11-01 22:13:44 +08:00
alvinfeiandClaude Opus 5.5 e786657a0b feat(memhub): 11 display languages; non-zh/en choices set the SDK prompt language
UI (packages/i18n, web, desktop):
- Locales: en, zh-Hans, zh-Hant, ja, es, de, pt, fr, ko, ru, id. zh-CN is
  now zh-Hans; a stored zh-CN preference (Desktop gui-language.json,
  browser localStorage) is read as zh-Hans, so existing users keep it.
- One complete catalog per new locale (packages/i18n/src/locales), every
  key with the same placeholders; product names stay untranslated.
- The language selector lists all 11 by their own names.
- The system language resolves to the new locales (zh-TW/HK/MO -> zh-Hant).
- The migration failure dialog and its GitHub issue prefill follow the
  display language (new "migration.*" catalog).
- Completeness test: every locale has every key, matching placeholders,
  no empty string; a missing key falls back to en.
- The language button keeps long names on one line (CSS ellipsis).

SDK prompt language (Core):
- config.json `ui_language` (written only once set), GET/PUT
  /api/v1/settings/ui-language. The Desktop main process sends the
  choice at launch and on every change; the browser UI on a change.
- zh-Hans or no choice ("system") passes no language: the SDK keeps its
  zh/en detection. Every other code, en included, is set as
  MemoryConfig.language in build_sdk_config, only when the pinned SDK has
  the field (else logged once at debug level).
- The language joins the read and write handle keys, so a change rebuilds
  clients with the #218 use-count retirement; collection names unchanged.
- Status: pipeline.ui_language and pipeline.sdk_language (auto or code).
- CLI: memhub settings language [<code>|system].

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 21:43:20 +08:00
128 changed files with 21216 additions and 392 deletions

No files matched your search

+38
View File
@@ -4,8 +4,46 @@ Hy Memory and MemHub are versioned and released independently.
## Unreleased
## MemHub 1.0.6 — 2026-11-02
Build 37, on `hy-memory==2.0.11`. Linux x64/arm64 Runtime and macOS arm64
App and Runtime.
- Interfaces in 11 languages: English, Simplified and Traditional Chinese,
Japanese, Spanish, German, Portuguese, French, Korean, Russian and
Indonesian. Choosing any language other than Simplified Chinese also sets
the SDK prompt language; Simplified Chinese keeps the automatic zh/en
detection.
- Memory modes: lite (no LLM, keeps raw conversations), pro (LLM extraction)
and ultra (pro plus the knowledge graph and a nightly evolve run). Set with
`GET`/`PUT /api/v1/settings/memory-mode` or `memhub settings mode`. Raw
conversations are now kept permanently. `POST /api/v2/evolve` and
`memhub evolve` run System 2 on demand.
- Safe embedding-model switching: an embedding-space registry, a switch that
pauses writes, re-embeds every user's vectors and the graph, verifies and
commits atomically, and resumes after a crash or an App update. Every start
scans the vector store and merges data left in other spaces. When the
embedding service is unavailable, search returns `embedding_unavailable`.
- Multi-user Runtime: memory APIs and the CLI take `user_id` (default
`local`); records are isolated per user; Hermes profiles can be connected as
separate users. The App keeps using `local`.
- Configurable SDK features through the CLI and API (`memhub settings
features`): graph rerank, episode recall, session episodes, episode
summaries, intention and few-shot. Defaults are unchanged.
- Search and recall pass through the SDK's search parameters with server-side
score floors; the memory library filters by layer; `/api/v2/raw` returns
data again; v2 source and import routes; profile version history.
- Upgrade fixes: migration no longer stops without a model profile (#234) or
on a pre-existing readback mismatch (#229, #240); source grants work after
the cutover (#233); reads use the active embedding (#242); the embedding
endpoint no longer follows the generation `base_url`.
- Hermes import drops gateway notices and probes and unwraps relayed
messages; `memhub logs` no longer reports valid older records as damaged.
## Hy Memory 2.0.11 — 2026-11-01
- Published
[`hy-memory==2.0.11`](https://pypi.org/project/hy-memory/2.0.11/).
- LLM prompts now ship in 11 languages: `en`, `zh-Hans`,
`zh-Hant`, `ja`, `es`, `de`, `pt`, `fr`, `ko`, `ru` and `id`. They live in
one registry, `hy_memory/prompts/`, with one module per language. Call
+1
View File
@@ -33,6 +33,7 @@ English | [中文](README.zh.md)
## Latest News
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Open-sourced** quantized **FP8** (**[Hy-Memory-30B-A3B-FP8](https://huggingface.co/tencent/Hy-Memory-30B-A3B-FP8)**) and **GGUF** (**[Hy-Memory-30B-A3B-GGUF](https://huggingface.co/tencent/Hy-Memory-30B-A3B-GGUF)**) versions of **Hy-Memory-30B-A3B** on Hugging Face!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-11]**: 📦 **Released [MemHub v1.0.6](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.6)** with interfaces in 11 languages, memory modes (lite / pro / ultra with nightly evolve), safe embedding-model switching, multi-user support for the CLI and API, and more reliable upgrades, on **`hy-memory==2.0.11`**. See the release notes for details.
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **Released [MemHub v1.0.5](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.5)** with Chinese, English and Spanish interfaces, one-time pairing codes for more custom Harnesses, and smoother browsing, search, startup and recovery, on **`hy-memory==2.0.8`**. See the release notes for details.
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **Released [MemHub v1.0.4](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.4)** and **[memhub-cli 1.0.5](https://www.npmjs.com/package/memhub-cli/v/1.0.5)**, with a signed macOS desktop app and verified macOS/Linux Runtimes!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Released** **Hy Memory 2.0 (`hy-memory==2.0.4`)** on [PyPI](https://pypi.org/project/hy-memory/2.0.4/) and open-sourced the **Hy-Memory** monorepo together with **[MemHub](memhub/README.md)**!
+1
View File
@@ -33,6 +33,7 @@
## 最新动态
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **模型开源** 在 Hugging Face 发布 **Hy-Memory-30B-A3B** 的 **FP8**(**[Hy-Memory-30B-A3B-FP8](https://huggingface.co/tencent/Hy-Memory-30B-A3B-FP8)**)与 **GGUF**(**[Hy-Memory-30B-A3B-GGUF](https://huggingface.co/tencent/Hy-Memory-30B-A3B-GGUF)**)量化版本模型!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-11]**: 📦 **正式发布 [MemHub v1.0.6](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.6)**:界面支持 11 种语言,新增记忆模式(lite / pro / ultra,ultra 每晚自动整理),可安全切换嵌入模型,CLI 和 API 支持多用户,升级更可靠;底层使用 **`hy-memory==2.0.11`**。详见发布说明。
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **正式发布 [MemHub v1.0.5](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.5)**:支持中文、英文和西班牙语界面,可通过一次性配对码连接更多自定义 Harness,并优化了记忆浏览、搜索、启动和异常恢复;底层使用 **`hy-memory==2.0.8`**。详见发布说明。
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **正式发布 [MemHub v1.0.4](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.4)** 与 **[memhub-cli 1.0.5](https://www.npmjs.com/package/memhub-cli/v/1.0.5)**,提供已签名的 macOS 桌面应用及经过验证的 macOS/Linux Runtime!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **正式发布** **Hy Memory 2.0 (`hy-memory==2.0.4`)**(已上线 [PyPI](https://pypi.org/project/hy-memory/2.0.4/)),并开源 **Hy-Memory** monorepo 与 **[MemHub](memhub/README.md)**!
+4 -4
View File
@@ -18,13 +18,13 @@ If you run into a problem, please open an Issue with the version, platform,repro
## 发行与平台合同
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.10`。MemHub 源码版本为 `1.0.5`
(build 36),GitHub 仓库为
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.11`。MemHub 源码版本为 `1.0.6`
(build 37),GitHub 仓库为
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
`memhub-cli`。当前代码架构见
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
`v1.0.5` 的发行目标是 Linux x64/arm64 Runtime 与 macOS arm64 App 和 Runtime。
`v1.0.6` 的发行目标是 Linux x64/arm64 Runtime 与 macOS arm64 App 和 Runtime。
Linux 制品由 GitHub Actions 在原生架构 runner 上构建并完成生命周期验收;macOS
制品由发行流水线完成 Developer ID 签名、公证和验收后手动上传。Windows 不支持。
只有同时出现在 GitHub Release 与 `release-manifest.json` 中、且通过公开校验的制品才属于
@@ -118,7 +118,7 @@ MemHub 的 SQLite 数据默认位于 `~/.one-memory`,不在 npm 包或 Runtime
## Hy Memory
SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.10`。SDK 配置、权限边界及 search
SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.11`。SDK 配置、权限边界及 search
返回格式见 [`MEMORY.md`](MEMORY.md)。HY search 返回的 `content` 已包含 SDK 统一生成的
逻辑时间和演化上下文;调用方不应再次转换时间戳或重复拼接 `When`。
+1 -1
View File
@@ -2352,7 +2352,7 @@
{
"SPDXID": "SPDXRef-Package-hy-memory",
"name": "hy-memory",
"versionInfo": "2.0.10",
"versionInfo": "2.0.11",
"downloadLocation": "NOASSERTION",
"filesAnalyzed": false,
"licenseConcluded": "Apache-2.0",
+1 -1
View File
@@ -9,7 +9,7 @@ Per-package license texts are not reproduced here yet; the license
column is filled in as part of release review, and the SBOM records
`NOASSERTION` until then rather than guessing.
## hy-memory 2.0.10
## hy-memory 2.0.11
License: Apache-2.0
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@one-memory/cli",
"version": "1.0.5",
"version": "1.0.6",
"description": "MemHub bootstrap CLI for installing and managing the local-first shared memory runtime",
"license": "Apache-2.0",
"repository": {
+41
View File
@@ -203,6 +203,13 @@ import {
renderFeaturesText,
runFeaturesCommand,
} from "./memory-features-flow.js";
import {
type LanguageDocument,
languageExitCode,
parseLanguageCommand,
renderLanguageText,
runLanguageCommand,
} from "./memory-language-flow.js";
import {
type EvolveDocument,
evolveExitCode,
@@ -2379,6 +2386,40 @@ async function runCommand(
);
return featuresExitCode(document);
}
if (
named === "settings" &&
arguments_.slice(1).find((value) => value !== "--json") === "language"
) {
const parsed = parseLanguageCommand(
arguments_
.slice(1)
.filter((value, index, all) => index !== all.indexOf("language")),
);
if (!parsed) return usage();
const connected = await connectRuntime(environment);
if (!connected.ok) {
const document: LanguageDocument = {
schema_version: 1,
product: PRODUCT,
command: "settings language",
state: "unavailable",
code: connected.code,
};
emit(
asJson ? `${JSON.stringify(document)}\n` : renderLanguageText(document),
);
return connected.exitCode;
}
const document = await runLanguageCommand(
parsed,
connected.reader,
connected.operations,
);
emit(
asJson ? `${JSON.stringify(document)}\n` : renderLanguageText(document),
);
return languageExitCode(document);
}
if (named === "settings") {
const parsed = parseSettingsCommand(arguments_.slice(1));
if (!parsed) return usage();
+8 -2
View File
@@ -461,12 +461,13 @@ export const COMMANDS: readonly CommandDescriptor[] = [
{
name: "settings",
summary:
"查看或切换记忆模式(lite / pro / ultra)。lite 不调用模型,只在原始对话里检索;pro 由模型提取记忆(System 1);ultra 在 pro 之上每晚自动进化(System 2,使用知识图谱)。任何模式都保留全部原始对话。切换不迁移数据:已处理的不会重做,排队中的和新的按新模式处理;需 --yes,不加时只显示计划。环境变量 ONE_MEMORY_GRAPH_STORE 会强制 ultra。settings features 查看或开关 SDK 的可选检索与提取功能(全局设置,默认全部保持 SDK 默认值):graph_rerank 图谱重排(仅 ultra)、same_series_timeline 同系列时间线(需 graph_rerank)、episode_recall 片段召回(pro / ultra,需先开 session_episodes 写入片段)、session_episodes 片段写入、episode_summary 片段摘要(每个片段一次模型调用)、intention 意图提取(默认开)、few_shot 提取示例;set 需 --yes,不加时只显示计划;reset 恢复默认值。",
"查看或切换记忆模式(lite / pro / ultra)。lite 不调用模型,只在原始对话里检索;pro 由模型提取记忆(System 1);ultra 在 pro 之上每晚自动进化(System 2,使用知识图谱)。任何模式都保留全部原始对话。切换不迁移数据:已处理的不会重做,排队中的和新的按新模式处理;需 --yes,不加时只显示计划。环境变量 ONE_MEMORY_GRAPH_STORE 会强制 ultra。settings features 查看或开关 SDK 的可选检索与提取功能(全局设置,默认全部保持 SDK 默认值):graph_rerank 图谱重排(仅 ultra)、same_series_timeline 同系列时间线(需 graph_rerank)、episode_recall 片段召回(pro / ultra,需先开 session_episodes 写入片段)、session_episodes 片段写入、episode_summary 片段摘要(每个片段一次模型调用)、intention 意图提取(默认开)、few_shot 提取示例;set 需 --yes,不加时只显示计划;reset 恢复默认值。settings language 查看或设置界面语言(App 切换语言时会自动设置):简体中文(zh-Hans)和 system 不指定 SDK 语言,SDK 照旧自动识别中文或英文;其余语言(en、zh-Hant、ja、es、de、pt、fr、ko、ru、id)会让提取等 SDK 提示使用该语言,立即生效。",
usage:
"memhub settings mode [lite|pro|ultra] [--yes] [--json]\n" +
" memhub settings features [--json]\n" +
" memhub settings features set <name>=<true|false> [...] [--yes] [--json]\n" +
" memhub settings features reset [<name> ...] [--json]",
" memhub settings features reset [<name> ...] [--json]\n" +
" memhub settings language [<code>|system] [--json]",
options: [
{
flag: "mode [lite|pro|ultra]",
@@ -481,6 +482,11 @@ export const COMMANDS: readonly CommandDescriptor[] = [
flag: "features reset [<name> ...]",
summary: "把指定功能(不写则全部)恢复为 SDK 默认值。",
},
{
flag: "language [<code>|system]",
summary:
"不带参数显示界面语言和 SDK 提示语言;带语言代码(en、zh-Hans、zh-Hant、ja、es、de、pt、fr、ko、ru、id)或 system 时设置。",
},
CONFIRM_OPTION,
JSON_OPTION,
],
+1
View File
@@ -23,6 +23,7 @@ const READ_ONLY_PATHS = [
"/api/v2/user-profile/versions",
"/api/v1/settings/memory-mode",
"/api/v1/settings/memory-features",
"/api/v1/settings/ui-language",
"/api/v2/evolve",
] as const;
/** One evolve run's receipt (`memhub evolve --wait`). */
+3
View File
@@ -31,6 +31,8 @@ const EVOLVE_PATH = "/api/v2/evolve";
const MEMORY_MODE_PATH = "/api/v1/settings/memory-mode";
/** `memhub settings features set|reset` sets the SDK features. */
const MEMORY_FEATURES_PATH = "/api/v1/settings/memory-features";
/** `memhub settings language <code>` sets the display language Core uses. */
const UI_LANGUAGE_PATH = "/api/v1/settings/ui-language";
const ONBOARDING_COMPLETE_PATH = "/api/v1/onboarding/complete";
const EMBEDDING_SWITCH_PATHS = [
"/api/v1/embedding-switches",
@@ -165,6 +167,7 @@ export function createCoreOperations(attachment: {
if (
path !== MEMORY_MODE_PATH &&
path !== MEMORY_FEATURES_PATH &&
path !== UI_LANGUAGE_PATH &&
(!SDK_MEMORY_PATH.test(path) || path.includes("?"))
) {
throw new Error("core_operation_path_not_allowed");
@@ -0,0 +1,274 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { run } from "./cli.js";
import { parseLanguageCommand } from "./memory-language-flow.js";
import { composeStatusReport, renderStatusText } from "./status-report.js";
const transport = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
}));
vi.mock("axios", () => ({
default: {
create: vi.fn(() => transport),
get: vi.fn(),
post: vi.fn(),
isAxiosError: () => false,
},
}));
const runtime = vi.hoisted(() => ({
inspectRuntimeStatus: vi.fn(),
discoverRuntimeAttachment: vi.fn(),
}));
vi.mock("@one-memory/runtime-control", async (importOriginal) => ({
...(await importOriginal<typeof import("@one-memory/runtime-control")>()),
...runtime,
}));
function languageState(uiLanguage: string | null, extra = {}) {
const sdk =
uiLanguage === null || uiLanguage === "zh-Hans" ? "auto" : uiLanguage;
return {
ui_language: uiLanguage,
sdk_language: sdk,
languages: [
"en",
"zh-Hans",
"zh-Hant",
"ja",
"es",
"de",
"pt",
"fr",
"ko",
"ru",
"id",
],
...extra,
};
}
beforeEach(() => {
vi.clearAllMocks();
for (const method of Object.values(transport)) method.mockReset();
runtime.inspectRuntimeStatus.mockResolvedValue({
state: "running",
code: "runtime_verified",
instanceId: "instance-1",
host: "127.0.0.1",
port: 8765,
version: "1.2.3",
});
runtime.discoverRuntimeAttachment.mockResolvedValue({
instanceId: "instance-1",
apiToken: "synthetic-runtime-token",
host: "127.0.0.1",
port: 8765,
servicePid: 4242,
health: {
status: "ok",
service: "one-memory-core",
version: "1.2.3",
instance_id: "instance-1",
},
});
transport.get.mockImplementation(async (url: string) =>
url === "/api/v1/settings/ui-language"
? { status: 200, data: languageState(null) }
: { status: 404, data: {} },
);
});
describe("memhub settings language", () => {
it("shows the display language and the SDK prompt language", async () => {
const result = await capturedRun(["settings", "language", "--json"]);
expect(result.code).toBe(0);
const json = JSON.parse(result.stdout);
expect(json).toMatchObject({
command: "settings language",
state: "ok",
language: { ui_language: null, sdk_language: "auto" },
});
expect(transport.put).not.toHaveBeenCalled();
const text = (await capturedRun(["settings", "language"])).stdout;
expect(text).toContain("界面语言:跟随系统(未选择)");
expect(text).toContain("SDK 提示语言:auto");
});
it("sets a language at once and reports the SDK language", async () => {
transport.put.mockResolvedValue({
status: 200,
data: languageState("ja", { rebuilt: true }),
});
const result = await capturedRun(["settings", "language", "ja", "--json"]);
expect(result.code).toBe(0);
expect(transport.put).toHaveBeenCalledWith(
"/api/v1/settings/ui-language",
{ ui_language: "ja" },
undefined,
);
expect(JSON.parse(result.stdout)).toMatchObject({
state: "committed",
rebuilt: true,
language: { ui_language: "ja", sdk_language: "ja" },
});
const text = (await capturedRun(["settings", "language", "ja"])).stdout;
expect(text).toContain("界面语言:ja(日本語)");
});
it("sends null for system and maps the legacy zh-CN code", async () => {
transport.put.mockResolvedValue({
status: 200,
data: languageState("zh-Hans", { rebuilt: false }),
});
await capturedRun(["settings", "language", "zh-CN"]);
expect(transport.put).toHaveBeenLastCalledWith(
"/api/v1/settings/ui-language",
{ ui_language: "zh-Hans" },
undefined,
);
transport.get.mockResolvedValue({
status: 200,
data: languageState("en"),
});
transport.put.mockResolvedValue({
status: 200,
data: languageState(null, { rebuilt: true }),
});
await capturedRun(["settings", "language", "system"]);
expect(transport.put).toHaveBeenLastCalledWith(
"/api/v1/settings/ui-language",
{ ui_language: null },
undefined,
);
});
it("does not write a language Core already has", async () => {
const result = await capturedRun(["settings", "language", "system"]);
expect(result.code).toBe(0);
expect(
JSON.parse(
(await capturedRun(["settings", "language", "system", "--json"]))
.stdout,
).state,
).toBe("unchanged");
expect(transport.put).not.toHaveBeenCalled();
});
it("refuses unknown codes before the Runtime", async () => {
for (const bad of [
["settings", "language", "it"],
["settings", "language", "EN"],
["settings", "language", "ja", "ko"],
]) {
expect((await capturedRun(bad)).code, bad.join(" ")).toBe(2);
}
expect(transport.put).not.toHaveBeenCalled();
});
it("parses the forms the help shows", () => {
expect(parseLanguageCommand([])).toEqual({ kind: "show" });
expect(parseLanguageCommand(["system"])).toEqual({
kind: "set",
language: null,
});
expect(parseLanguageCommand(["zh-Hant", "--json"])).toEqual({
kind: "set",
language: "zh-Hant",
});
});
});
describe("status language", () => {
it.each([
[null, "auto", "跟随系统", "auto(自动识别中/英)"],
["ja", "ja", "ja", "SDK 提示语言 ja"],
])(
"shows the UI language %s and the SDK language %s",
async (uiLanguage, sdkLanguage, uiText, sdkText) => {
const processing = {
pending: 0,
completed: 0,
failed: 0,
blocked_stages: [],
pipeline: {
mode: "pro",
configured_mode: "pro",
forced_by_env: false,
last_evolve_at: null,
graph_build: { state: "idle", users: {} },
evolve: { mode: "pro", schedule: null, running: [], users: {} },
features: {},
ui_language: uiLanguage,
sdk_language: sdkLanguage,
},
};
const report = await composeStatusReport({
dataDir: "/tmp/isolated",
runtime: { state: "running", code: "ok", host: "127.0.0.1", port: 1 },
background: null,
reader: (async (path: string) =>
path === "/api/v1/memory-processing"
? { state: "ok", data: processing }
: { state: "unavailable", code: "x" }) as never,
});
expect(report.pipeline?.ui_language).toBe(uiLanguage);
expect(report.pipeline?.sdk_language).toBe(sdkLanguage);
const text = renderStatusText(report, "MemHub");
expect(text).toContain(uiText);
expect(text).toContain(sdkText);
},
);
it("reads an older Runtime without the setting as auto", async () => {
const report = await composeStatusReport({
dataDir: "/tmp/isolated",
runtime: { state: "running", code: "ok", host: "127.0.0.1", port: 1 },
background: null,
reader: (async () => ({
state: "ok",
data: {
pipeline: {
mode: "pro",
configured_mode: "pro",
evolve: {},
graph_build: {},
},
},
})) as never,
});
expect(report.pipeline?.sdk_language).toBe("auto");
expect(report.pipeline?.ui_language).toBeNull();
});
});
async function capturedRun(arguments_: string[]) {
let stdout = "";
let stderr = "";
const stdoutSpy = vi
.spyOn(process.stdout, "write")
.mockImplementation((chunk) => {
stdout += String(chunk);
return true;
});
const stderrSpy = vi
.spyOn(process.stderr, "write")
.mockImplementation((chunk) => {
stderr += String(chunk);
return true;
});
try {
return {
code: await run(arguments_, { ONE_MEMORY_DATA_DIR: "/synthetic/data" }),
stdout,
stderr,
};
} finally {
stdoutSpy.mockRestore();
stderrSpy.mockRestore();
}
}
+184
View File
@@ -0,0 +1,184 @@
/**
* `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
* finishes in the old language, the next ones use the new one. The App sends
* its own choice again whenever it changes there.
*/
import type { CoreReader } from "./core-client.js";
import type { CorePutOperations } from "./core-operations.js";
import { PRODUCT } from "./version.js";
export const UI_LANGUAGES = [
"en",
"zh-Hans",
"zh-Hant",
"ja",
"es",
"de",
"pt",
"fr",
"ko",
"ru",
"id",
] as const;
export type UiLanguage = (typeof UI_LANGUAGES)[number];
/** Codes earlier builds used, and the code each one is now. */
const LEGACY: Record<string, UiLanguage> = { "zh-CN": "zh-Hans" };
const NAMES: Record<UiLanguage, string> = {
en: "English",
"zh-Hans": "简体中文",
"zh-Hant": "繁體中文",
ja: "日本語",
es: "Español",
de: "Deutsch",
pt: "Português",
fr: "Français",
ko: "한국어",
ru: "Русский",
id: "Bahasa Indonesia",
};
export type LanguageCommand =
| { kind: "show" }
| { kind: "set"; language: UiLanguage | null };
function isUiLanguage(value: string): value is UiLanguage {
return (UI_LANGUAGES as readonly string[]).includes(value);
}
/** Arguments after `settings language`; null = usage. */
export function parseLanguageCommand(
values: readonly string[],
): LanguageCommand | null {
const rest = values.filter((value) => value !== "--json");
if (rest.length === 0) return { kind: "show" };
if (rest.length !== 1) return null;
const raw = rest[0] ?? "";
const value: string = Object.hasOwn(LEGACY, raw) ? LEGACY[raw] : raw;
if (value === "system") return { kind: "set", language: null };
return isUiLanguage(value) ? { kind: "set", language: value } : null;
}
export interface LanguageState {
ui_language: UiLanguage | null;
sdk_language: string;
rebuilt?: boolean;
}
function languageState(value: unknown): LanguageState | null {
if (!value || typeof value !== "object") return null;
const item = value as Record<string, unknown>;
if (
!(item.ui_language === null || isUiLanguage(String(item.ui_language))) ||
typeof item.sdk_language !== "string"
) {
return null;
}
return {
ui_language: item.ui_language as UiLanguage | null,
sdk_language: item.sdk_language,
...(typeof item.rebuilt === "boolean" ? { rebuilt: item.rebuilt } : {}),
};
}
export type LanguageDocument = {
schema_version: 1;
product: string;
command: "settings language";
} & (
| { state: "ok" | "unchanged"; language: LanguageState }
| { state: "committed"; language: LanguageState; rebuilt: boolean }
| { state: "rejected" | "unknown" | "unavailable"; code: string }
);
const BASE = {
schema_version: 1 as const,
product: PRODUCT,
command: "settings language" as const,
};
const PATH = "/api/v1/settings/ui-language";
export async function runLanguageCommand(
command: LanguageCommand,
reader: CoreReader,
operations: CorePutOperations,
): Promise<LanguageDocument> {
const read = await reader<unknown>(PATH);
if (read.state !== "ok") {
return { ...BASE, state: "unavailable", code: read.code };
}
const current = languageState(read.data);
if (current === null) {
return { ...BASE, state: "unavailable", code: "response_invalid" };
}
if (command.kind === "show") {
return { ...BASE, state: "ok", language: current };
}
if (current.ui_language === command.language) {
return { ...BASE, state: "unchanged", language: current };
}
const result = await operations.put<unknown>(PATH, {
ui_language: command.language,
});
if (result.state !== "committed") {
return { ...BASE, state: result.state, code: result.code };
}
const changed = languageState(result.data);
if (changed === null) {
return { ...BASE, state: "unknown", code: "response_invalid" };
}
return {
...BASE,
state: "committed",
language: changed,
rebuilt: changed.rebuilt === true,
};
}
export function languageExitCode(document: LanguageDocument): number {
return ["ok", "unchanged", "committed"].includes(document.state) ? 0 : 1;
}
function sdkText(sdkLanguage: string): string {
return sdkLanguage === "auto"
? "auto(SDK 自动识别中文或英文)"
: `${sdkLanguage}(提取等 SDK 提示使用该语言)`;
}
export function renderLanguageText(document: LanguageDocument): string {
if ("code" in document) {
return `界面语言未能${document.state === "unavailable" ? "读取" : "修改"}:${document.code}\n`;
}
const state = document.language;
const lines = [
`界面语言:${
state.ui_language === null
? "跟随系统(未选择)"
: `${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 使用该语言。`,
);
}
if (document.state === "unchanged") lines.push("没有需要改动的设置。");
if (document.state === "committed") {
lines.push(
document.rebuilt
? "已更新。正在进行的写入按原语言完成,之后的写入和检索使用新语言。"
: "已保存,SDK 提示语言没有变化。",
);
}
return `${lines.join("\n")}\n`;
}
+1 -1
View File
@@ -142,7 +142,7 @@ describe("memhub settings mode", () => {
["settings", "mode", "max"],
["settings", "mode", "--yes"],
["settings", "mode", "lite", "pro"],
["settings", "language", "en"],
["settings", "theme", "dark"],
]) {
expect((await capturedRun(bad)).code, bad.join(" ")).toBe(2);
}
+15
View File
@@ -86,6 +86,10 @@ export interface PipelineSummary {
graph_build: string | null;
/** SDK features (`memory_features`) whose value differs from the default. */
features: Record<string, boolean>;
/** The App's display language; null = never chosen or "follow the system". */
ui_language: string | null;
/** The SDK prompt language: `auto` (zh/en detection) or the code passed. */
sdk_language: string;
}
const MODES = new Set(["lite", "pro", "ultra"]);
@@ -158,6 +162,10 @@ export function describePipeline(value: unknown): PipelineSummary | null {
evolve_users: users,
graph_build: typeof graph.state === "string" ? graph.state : null,
features,
ui_language: typeof item.ui_language === "string" ? item.ui_language : null,
// An older Runtime has no setting: it always left the SDK on auto.
sdk_language:
typeof item.sdk_language === "string" ? item.sdk_language : "auto",
};
}
@@ -353,6 +361,13 @@ export function pipelineLine(pipeline: PipelineSummary): string {
.join("、")}`,
);
}
parts.push(
`;界面语言 ${pipeline.ui_language ?? "跟随系统"},SDK 提示语言 ${
pipeline.sdk_language === "auto"
? "auto(自动识别中/英)"
: pipeline.sdk_language
}`,
);
return parts.join("");
}
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "uv_build"
[project]
name = "one-memory"
version = "1.0.5"
version = "1.0.6"
description = "MemHub local-first memory core"
readme = "README.md"
requires-python = ">=3.12"
@@ -12,7 +12,7 @@ dependencies = [
"fastapi>=0.141.1",
"filelock>=3.32.4",
# Public SDK version validated against MemHub's permission and recall contracts.
"hy-memory==2.0.10",
"hy-memory==2.0.11",
# chromadb arrives through the HY SDK; locked here so an unrelated upgrade
# cannot move the SDK's store. Built-in embedding consumes numpy directly.
"chromadb==1.5.9",
+102
View File
@@ -300,6 +300,12 @@ from one_memory.source_read_access import SourceReadAccess
from one_memory.source_totals import SourceTotals
from one_memory.strict_query import forbid_unknown_query_params
from one_memory.target_schema import bootstrap_fresh_management_schema
from one_memory.ui_language import (
SDK_LANGUAGE_AUTO,
UI_LANGUAGES,
UiLanguage,
sdk_language,
)
from one_memory.user_scope import (
DEFAULT_USER_ID,
MULTI_USER_SOURCES,
@@ -759,6 +765,38 @@ class PipelineStatus(BaseModel):
#: The SDK features (`memory_features`) whose value differs from the SDK
#: default; empty while every feature is at its default.
features: dict[MemoryFeatureName, bool] = Field(default_factory=dict)
#: The display language the App last reported (`ui_language`); null =
#: never chosen or "follow 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
class UiLanguageState(BaseModel):
"""`GET /api/v1/settings/ui-language`."""
model_config = ConfigDict(extra="forbid")
#: What the App reported; null = never chosen or "follow the system".
ui_language: UiLanguage | None
#: What SDK clients are built with: `auto` (zh-Hans or no choice: the SDK
#: detects zh/en, as before) or the code passed as `MemoryConfig.language`.
sdk_language: str
languages: tuple[UiLanguage, ...] = UI_LANGUAGES
class UiLanguageUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
#: A supported code, or null for "follow the system" (SDK auto-detect).
ui_language: UiLanguage | None
class UiLanguageChange(UiLanguageState):
"""`PUT /api/v1/settings/ui-language`."""
#: Whether the SDK language changed, so the SDK clients were rebuilt.
rebuilt: bool
class MemoryFeatureState(BaseModel):
@@ -1303,6 +1341,7 @@ def create_app(
memory_features=lambda: _memory_features(app),
spaces=app.state.embedding_spaces,
writes_open=lambda: _embedding_writes_open(app),
sdk_language=lambda: _sdk_language(app),
)
app.state.sdk_memory_runtime = SdkMemoryRuntime(
coordinator=app.state.sdk_write_coordinator,
@@ -4560,6 +4599,53 @@ def create_app(
changed=changed,
)
@app.get(
"/api/v1/settings/ui-language",
response_model=UiLanguageState,
dependencies=strict,
)
def get_ui_language() -> UiLanguageState:
"""The App's display language and the SDK prompt language it implies."""
return _ui_language_state(app)
@app.put(
"/api/v1/settings/ui-language",
response_model=UiLanguageChange,
dependencies=strict,
)
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
SDK clients like a feature change: a write in flight finishes in the
old language. Nothing stored is migrated and no collection is renamed.
"""
store = _config_store(app)
async with app.state.memory_mode_lock:
previous = _sdk_language(app)
if store.ui_language() != request.ui_language:
await asyncio.to_thread(store.set_ui_language, request.ui_language)
current = _sdk_language(app)
rebuilt = current != previous
if rebuilt:
coordinator = getattr(app.state, "sdk_write_coordinator", None)
if coordinator is not None:
await coordinator.retire_stale_handles()
# Reads rebuild on their next request (`ActiveProfileReadHandle`).
diagnostic_event(
level="info",
component="memory.language",
event="sdk_language.changed",
outcome="succeeded",
details={
"sdk_language": current or SDK_LANGUAGE_AUTO,
"previous": previous or SDK_LANGUAGE_AUTO,
},
)
return UiLanguageChange(**_ui_language_state(app).model_dump(), rebuilt=rebuilt)
@app.get(
"/api/v1/settings/evolve-schedule",
response_model=EvolveSchedule,
@@ -5922,6 +6008,7 @@ def _sdk_client_handle(app: FastAPI) -> SdkClientHandle:
memory_mode=lambda: _memory_mode(app),
features=lambda: _memory_features(app),
spaces=getattr(app.state, "embedding_spaces", None),
language=lambda: _sdk_language(app),
)
app.state.sdk_client_handle = handle
return handle
@@ -5964,6 +6051,20 @@ def _memory_features(app: FastAPI) -> FeatureOverrides:
return store.memory_features().overrides() if store is not None else ()
def _sdk_language(app: FastAPI) -> str | None:
"""The SDK prompt language every client is built with; None = auto-detect."""
store = getattr(app.state, "config_store", None)
return sdk_language(store.ui_language()) if store is not None else None
def _ui_language_state(app: FastAPI) -> UiLanguageState:
store = getattr(app.state, "config_store", None)
return UiLanguageState(
ui_language=store.ui_language() if store is not None else None,
sdk_language=_sdk_language(app) or SDK_LANGUAGE_AUTO,
)
def _memory_features_state(
app: FastAPI, defaults: dict[str, bool] | None = None
) -> MemoryFeaturesState:
@@ -6019,6 +6120,7 @@ def _pipeline_status(app: FastAPI) -> PipelineStatus | None:
dict[MemoryFeatureName, bool],
non_default_features(_memory_features(app), sdk_feature_defaults()),
),
**_ui_language_state(app).model_dump(exclude={"languages"}),
)
@@ -14,7 +14,7 @@ from pathlib import Path
from typing import Literal, cast
HY_MEMORY_DISTRIBUTION = "hy-memory"
HY_MEMORY_VERSION = "2.0.10"
HY_MEMORY_VERSION = "2.0.11"
HY_MEMORY_NAMESPACE = "governed-v2"
DiagnosticLogLevel = Literal["error", "warning", "info", "debug"]
@@ -503,7 +503,7 @@ def _ensure_coding_schema(path: Path) -> None:
CodingMemoryStore,
)
# Relies on the pinned SDK's internals (hy-memory==2.0.10 is pinned exactly).
# Relies on the pinned SDK's internals (hy-memory==2.0.11 is pinned exactly).
store = CodingMemoryStore.__new__(CodingMemoryStore)
store._db_path = str(path)
store._conn = None
@@ -15,7 +15,7 @@ from collections.abc import Callable, Coroutine, Iterable, Iterator, Mapping
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Any, ClassVar, Literal, cast
from typing import Any, ClassVar, Literal, TypedDict, cast
from hy_memory import ( # type: ignore[import-untyped]
AmbiguousMemoryIdError as AmbiguousMemoryIdError, # re-export for api.py
@@ -191,6 +191,7 @@ class SdkClientHandle:
space: EmbeddingSpace | None = None,
mode: str | None = None,
features: FeatureOverrides = (),
language: str | None = None,
) -> None:
self._profile = profile
self._api_key = api_key
@@ -201,6 +202,8 @@ class SdkClientHandle:
self.mode = mode or effective_memory_mode()
#: The SDK features set on its config (`memory_features`), fixed likewise.
self.features = features
#: The SDK prompt language (`ui_language`); None = SDK auto-detect.
self.language = language
self._lease: ClientLease | None = None
self._constructing: asyncio.Future[ClientLease] | None = None
self._lock = asyncio.Lock()
@@ -232,6 +235,7 @@ class SdkClientHandle:
http_transport_factory=self._http_transport_factory,
mode=self.mode,
features=self.features,
**language_kwargs(self.language),
)
)
self._constructing = constructing
@@ -347,6 +351,31 @@ def apply_sdk_features(
return config
class LanguageKwargs(TypedDict, total=False):
language: str
def language_kwargs(language: str | None) -> LanguageKwargs:
"""`language=` for a client constructor, only when there is one.
Without a language every call is the one it was before the setting
existed, so stand-ins written for that call keep working.
"""
return {"language": language} if language is not None else {}
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
the collection identity never depends on it.
"""
if language is not None:
config.language = language
return config
def build_sdk_config(
profile: ModelProfileSnapshot,
space: EmbeddingSpace | None = None,
@@ -355,6 +384,7 @@ def build_sdk_config(
api_key: str | None,
data_dir: Path,
features: Mapping[str, bool] | Iterable[tuple[str, bool]] = (),
language: str | None = None,
) -> MemoryConfig:
"""Map a model snapshot and its embedding space to an SDK configuration.
@@ -365,7 +395,8 @@ def build_sdk_config(
the registry existed is used: offline tools on a home with no registry.
`mode` never changes a name, and neither do `features`
(`memory_features`), which are set on top; with none the config is
exactly what it was before the setting existed.
exactly what it was before the setting existed. So is `language`
(`apply_sdk_language`); None leaves the SDK's own zh/en detection.
"""
if mode is not None and mode not in {"lite", "pro", "ultra"}:
raise HySdkRuntimeError("hy_sdk_memory_mode_invalid")
@@ -453,7 +484,7 @@ def build_sdk_config(
"coding": {"db_path": str(root / "coding.sqlite3")},
}
)
return apply_sdk_features(config, features)
return apply_sdk_language(apply_sdk_features(config, features), language)
def _acquire_for_handle(
@@ -465,6 +496,7 @@ def _acquire_for_handle(
http_transport_factory: Callable[[], Any] | None,
mode: str | None,
features: FeatureOverrides = (),
language: str | None = None,
) -> ClientLease:
# `acquire_client` is looked up at call time, so a stand-in replaces every
# construction. A handle without a space keeps the call it always made.
@@ -477,6 +509,7 @@ def _acquire_for_handle(
mode=mode,
features=features,
**named,
**language_kwargs(language),
)
@@ -489,6 +522,7 @@ def acquire_client(
http_transport_factory: Callable[[], Any] | None = None,
mode: str | None = None,
features: FeatureOverrides = (),
language: str | None = None,
) -> ClientLease:
"""Construct a public SDK client and return its explicit runtime lease.
@@ -496,7 +530,8 @@ def acquire_client(
pipeline mode (lite / pro / ultra) and never changes a name; None keeps
the pre-setting default (ultra when `ONE_MEMORY_GRAPH_STORE` is set, else
pro). `features` are the `memory_features` set on its config; none keeps
every SDK default. Waits while `hold_stores_offline` holds this home's stores.
every SDK default. `language` is the SDK prompt language; None
auto-detects. Waits while `hold_stores_offline` holds this home's stores.
"""
key = data_dir.resolve()
with _STORE_GATE:
@@ -513,6 +548,7 @@ def acquire_client(
mode=mode or effective_memory_mode(),
features=features,
**named,
**language_kwargs(language),
)
finally:
with _STORE_GATE:
@@ -568,6 +604,7 @@ def _acquire_client(
http_transport_factory: Callable[[], Any] | None,
mode: str = "pro",
features: FeatureOverrides = (),
language: str | None = None,
) -> ClientLease:
config = build_sdk_config(
profile,
@@ -576,6 +613,7 @@ def _acquire_client(
api_key=api_key,
data_dir=data_dir,
features=features,
language=language,
)
config.http_transport_factory = http_transport_factory
mismatch = inspect_graph_width_mismatch(config)
@@ -19,6 +19,7 @@ from pydantic import BaseModel, ConfigDict, Field, StrictBool, field_validator
from one_memory.memory_features import MemoryFeatures
from one_memory.memory_queue import ExtractionWindowPreset
from one_memory.ui_language import UI_LANGUAGES, UiLanguage
_SOURCE_ID = re.compile(r"^[a-z0-9][a-z0-9-]{0,127}$")
@@ -73,6 +74,10 @@ 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.
ui_language: UiLanguage | None = None
@field_validator("sources")
@classmethod
@@ -95,7 +100,7 @@ class ConfigFileError(RuntimeError):
pass
_WRITTEN_WHEN_SET = frozenset({"memory_mode", "evolve_schedule", "memory_features"})
_WRITTEN_WHEN_SET = frozenset({"memory_mode", "evolve_schedule", "memory_features", "ui_language"})
class ConfigStore:
@@ -183,6 +188,19 @@ class ConfigStore:
self._document = updated
return updated
def ui_language(self) -> UiLanguage | None:
with self._lock:
return self._document.ui_language
def set_ui_language(self, language: UiLanguage | None) -> ConfigDocument:
if language is not None and language not in UI_LANGUAGES:
raise ValueError("invalid_ui_language")
with self._lock:
updated = self._document.model_copy(update={"ui_language": language})
self._write(updated)
self._document = updated
return updated
def migrate_extraction_window_preset(self, preset: ExtractionWindowPreset) -> ConfigDocument:
"""Adopt the legacy SQL value only when config.json has no value."""
if not isinstance(preset, ExtractionWindowPreset):
@@ -17,9 +17,9 @@ task (one HTTP request, one retention pass).
The memory mode (lite / pro / ultra) is fixed per SDK client, so a mode change
rebuilds and retires the same way, and so do the SDK features
(`memory_features`): handles are keyed by (space, profile, mode, features).
Neither mode nor features names a collection; both are separate client
parameters.
(`memory_features`) and the SDK prompt language (`ui_language`): handles are
keyed by (space, profile, mode, features, language). None of mode, features
or language names a collection; each is a separate client parameter.
"""
from __future__ import annotations
@@ -42,6 +42,7 @@ from one_memory.hy_sdk_runtime import (
SdkClientHandle,
builtin_retrieval_snapshot,
embedding_endpoint,
language_kwargs,
retrieval_view,
)
from one_memory.hy_sdk_types import HySdkRuntimeError
@@ -50,8 +51,8 @@ from one_memory.model_profiles import ModelProfileSnapshot, ModelProfileStore, P
from one_memory.secrets import SecretStore, SecretStoreUnavailableError
_BUILTIN = (BUILTIN_RETRIEVAL_PROFILE_ID, 0)
#: (space id, profile id, revision, memory mode, features)
_HandleKey = tuple[str, str, int, str, FeatureOverrides]
#: (space id, profile id, revision, memory mode, features, SDK language)
_HandleKey = tuple[str, str, int, str, FeatureOverrides, str | None]
class ActiveProfileReadHandle(SdkClientHandle):
@@ -67,6 +68,7 @@ class ActiveProfileReadHandle(SdkClientHandle):
features: Callable[[], FeatureOverrides] = lambda: (),
spaces: EmbeddingSpaceStore | None = None,
health: EmbeddingHealth | None = None,
language: Callable[[], str | None] = lambda: None,
) -> None:
super().__init__(builtin_retrieval_snapshot(), api_key=None, data_dir=data_dir)
self.profiles = profiles
@@ -74,6 +76,7 @@ class ActiveProfileReadHandle(SdkClientHandle):
self.data_dir = data_dir
self.memory_mode = memory_mode
self.current_features = features
self.current_language = language
#: None only for a profile store without a database (test doubles).
self.spaces = spaces if spaces is not None else registry_for(profiles)
self.health = health or EmbeddingHealth()
@@ -111,13 +114,22 @@ class ActiveProfileReadHandle(SdkClientHandle):
"""The handle of the live space, held for the asking task."""
space, identity = await asyncio.to_thread(self._resolve)
mode, features = self.memory_mode(), self.current_features()
key = (space.space_id if space is not None else "", *identity, mode, features)
language = self.current_language()
key = (
space.space_id if space is not None else "",
*identity,
mode,
features,
language,
)
async with self._handle_lock:
handle = self._handles.get(key)
if handle is None:
# Only on a change of space or profile: resolving a key can
# reach the system keychain, so it is not done on every read.
handle = await asyncio.to_thread(self._build, space, identity, mode, features)
handle = await asyncio.to_thread(
self._build, space, identity, mode, features, language
)
self._handles[key] = handle
self._retired.update(value for name, value in self._handles.items() if name != key)
self._handles = {key: handle}
@@ -204,10 +216,12 @@ class ActiveProfileReadHandle(SdkClientHandle):
identity: tuple[str, int],
mode: str,
features: FeatureOverrides = (),
language: str | None = None,
) -> SdkClientHandle:
# Without a registry (a profile store with no database) the handle
# opens the space its profile names, exactly as before.
named: dict[str, Any] = {"space": space} if space is not None else {}
named.update(language_kwargs(language))
if identity == _BUILTIN:
return SdkClientHandle(
builtin_retrieval_snapshot(),
@@ -16,7 +16,11 @@ from one_memory.embedding_spaces import (
registry_for,
)
from one_memory.hy_graph_admin import effective_memory_mode
from one_memory.hy_sdk_runtime import SdkClientHandle, builtin_retrieval_snapshot
from one_memory.hy_sdk_runtime import (
SdkClientHandle,
builtin_retrieval_snapshot,
language_kwargs,
)
from one_memory.imports.models import EvidenceContentKind, EvidenceOrigin, EvidenceRecord
from one_memory.input_sanitization import sanitize_model_input
from one_memory.memory_features import FeatureOverrides
@@ -153,10 +157,11 @@ class SdkWriteCoordinator:
the SharedRuntime closed mid-`add`, the job failed with AttributeError
after HY was invoked, and it was left `unknown`.
Handles are keyed by profile, memory mode and the SDK features
(`memory_features`), so a mode or feature change retires handles the same
way. In lite no generation model is needed: without an
active profile the built-in local embedder writes raw turns.
Handles are keyed by profile, memory mode, the SDK features
(`memory_features`) and the SDK language (`ui_language`), so a mode,
feature or language change retires handles the same way. In lite no
generation model is needed: without an active profile the built-in
local embedder writes raw turns.
`exclusive_for_evolve(user_id)` keeps evolve and that user's writes
apart: it waits for the user's write in flight, and while it runs that
@@ -182,6 +187,7 @@ class SdkWriteCoordinator:
memory_features: Callable[[], FeatureOverrides] = lambda: (),
spaces: EmbeddingSpaceStore | None = None,
writes_open: Callable[[], bool] = lambda: True,
sdk_language: Callable[[], str | None] = lambda: None,
) -> None:
self.profiles = profiles
self.secrets = secrets
@@ -190,8 +196,12 @@ class SdkWriteCoordinator:
self.sedimentation_enabled = sedimentation_enabled
self.memory_mode = memory_mode
self.memory_features = memory_features
#: Keyed by (profile id, revision, space id, memory mode, features).
self._handles: dict[tuple[str, int, str, str, FeatureOverrides], SdkClientHandle] = {}
self.sdk_language = sdk_language
#: Keyed by (profile id, revision, space id, memory mode, features,
#: SDK language).
self._handles: dict[
tuple[str, int, str, str, FeatureOverrides, str | None], SdkClientHandle
] = {}
#: Users evolve (or the graph backfill) is running for, the writes in
#: flight per user, and one exclusive run per user at a time. Writes
#: of one user still run side by side; only evolve waits for them.
@@ -244,13 +254,13 @@ class SdkWriteCoordinator:
self._writes_idle.notify_all()
async def retire_stale_handles(self) -> None:
"""After a mode or feature change: retire handles built otherwise.
"""After a mode, feature or language change: retire handles built otherwise.
The idle ones close now. A handle still writing is closed when its
last use ends (#218), so a write that started in the old mode, or
with the old features, finishes in it.
"""
build = (self.memory_mode(), self.memory_features())
build = (self.memory_mode(), self.memory_features(), self.sdk_language())
async with self._lock:
stale = {key: handle for key, handle in self._handles.items() if key[3:] != build}
for key in stale:
@@ -332,10 +342,17 @@ class SdkWriteCoordinator:
"""Use the active profile's handle; retire the others, closing the unused."""
mode = self.memory_mode()
features = self.memory_features()
language = self.sdk_language()
profile_identity, profile, secret, space = await asyncio.to_thread(
self._read_active_credentials, mode
)
key = (*profile_identity, space.space_id if space is not None else "", mode, features)
key = (
*profile_identity,
space.space_id if space is not None else "",
mode,
features,
language,
)
async with self._lock:
handle = self._handles.get(key)
if handle is None:
@@ -347,6 +364,7 @@ class SdkWriteCoordinator:
mode=mode,
features=features,
space=space,
**language_kwargs(language),
)
self._handles[key] = handle
self._retired.update(value for name, value in self._handles.items() if name != key)
@@ -0,0 +1,73 @@
"""The display language a person chose, and the SDK prompt language it implies.
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.
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.
The language never names a collection; a change rebuilds the SDK handles the
way a mode or feature change does (#218 use count).
No SDK import here: only `hy_sdk_runtime` and `hy_sdk_migration` import it.
`config_state` imports this module, so it imports nothing from there.
"""
from __future__ import annotations
from typing import Literal
UiLanguage = Literal[
"en",
"zh-Hans",
"zh-Hant",
"ja",
"es",
"de",
"pt",
"fr",
"ko",
"ru",
"id",
]
UI_LANGUAGES: tuple[UiLanguage, ...] = (
"en",
"zh-Hans",
"zh-Hant",
"ja",
"es",
"de",
"pt",
"fr",
"ko",
"ru",
"id",
)
#: 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"
def normalize_ui_language(value: object) -> UiLanguage | None:
"""A supported code (legacy ones mapped), or None for anything else."""
if not isinstance(value, str):
return None
value = LEGACY_UI_LANGUAGES.get(value, value)
return value if value in UI_LANGUAGES else 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
+2 -1
View File
@@ -304,6 +304,7 @@
| `test_sync_facade.py` | `contract` | A2: the sync facade must keep the sync shape call sites already use. |
| `test_tail_metadata_lane.py` | `contract` | Post-response metadata retries must be bounded and never block the route. |
| `test_two_level_discovery.py` | `contract` | 3925a kernel item 1: two-level discovery. |
| `test_ui_language.py` | `contract` | The App's display language, and the SDK prompt language it implies. |
| `test_ui_session.py` | `contract` | 覆盖 exchange is single use and creates a session、expired exchange cannot create a session、exchange is bound to the core instance 等 5 个场景。 |
| `test_ui_session_api.py` | `integration` | 覆盖 ui session exchange sets httponly strict cookie、ui session requires bearer for exchange、ui session reports missing web assets 场景。 |
| `test_unanswered_turn.py` | `contract` | BUG-06: which conversations end on a question nobody answered. |
@@ -317,4 +318,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. |
共 312 个测试文件。
共 313 个测试文件。
@@ -14,8 +14,8 @@ from pathlib import Path
import pytest
PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live")
EXPECTED_TEST_FILE_COUNT = 312
EXPECTED_TEST_FILES_SHA256 = "7d0dc6efd88d71d5275bea8016a0ff087ff7568abcb8fd01e5f82c77a08a484f"
EXPECTED_TEST_FILE_COUNT = 313
EXPECTED_TEST_FILES_SHA256 = "dcada5d5421767196a79e415dd47c2b6c6d67681972dbe81481b31867d318928"
# Pure transformations, validation models, parity checks and state machines.
# This list is intentionally small: uncertain files default to contract.
@@ -91,7 +91,7 @@ def test_bundle_contains_management_sdk_config_and_verified_manifest(tmp_path):
assert manifest["bundle_id"] == "migration-1"
assert manifest["sdk_namespace"] == "governed-v2"
assert manifest["runtime_build"] == "build-13"
assert manifest["snapshot_sdk_version"] == "2.0.10"
assert manifest["snapshot_sdk_version"] == "2.0.11"
paths = {item["path"]: item for item in manifest["files"]}
assert {
"management/one-memory.sqlite3",
+316
View File
@@ -0,0 +1,316 @@
"""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
handles the way a mode or feature change does, and the language never touches
the vector collection identity.
"""
from __future__ import annotations
import asyncio
import dataclasses
import json
from contextlib import asynccontextmanager
from pathlib import Path
from types import SimpleNamespace
import pytest
from httpx import ASGITransport, AsyncClient
from pydantic import SecretStr, ValidationError
from one_memory import hy_sdk_runtime, sdk_read_handle, sdk_write_processor
from one_memory.api import create_app
from one_memory.config import Settings
from one_memory.memory_server.config_state import ConfigDocument, ConfigStore
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")
@pytest.fixture(autouse=True)
def _clean_sdk_env(monkeypatch):
"""The SDK's own `MEMORY_LANGUAGE` feeds its default; tests start without it."""
monkeypatch.delenv("MEMORY_LANGUAGE", raising=False)
def _config(tmp_path: Path, language: str | None):
return hy_sdk_runtime.build_sdk_config(
hy_sdk_runtime.builtin_retrieval_snapshot(),
api_key=None,
data_dir=tmp_path,
language=language,
)
# --- the rule -------------------------------------------------------------------
def test_eleven_codes_and_the_legacy_chinese_code() -> None:
assert ("en", "zh-Hans", *PASSED[1:]) == 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"
assert normalize_ui_language("system") is None
assert normalize_ui_language("xx") is None
assert normalize_ui_language(None) is None
def test_only_zh_hans_and_no_choice_leave_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
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)))
assert _config(tmp_path, None).language is None
assert repr(dataclasses.asdict(_config(tmp_path, sdk_language("zh-Hans")))) == before
@pytest.mark.parametrize("code", PASSED)
def test_a_passed_code_reaches_the_memory_config(tmp_path, code) -> None:
config = _config(tmp_path, sdk_language(code))
assert config.language == code
def test_the_language_never_changes_the_collection_identity(tmp_path) -> None:
def identity(config):
return (
dataclasses.asdict(config.vector_store),
config.embedder.model,
config.embedder.embedding_dims,
config.graph_store.db_path,
config.cache.db_path,
)
baseline = identity(_config(tmp_path, None))
for code in PASSED:
assert identity(_config(tmp_path, code)) == baseline
def test_acquire_client_passes_the_language_only_when_there_is_one(tmp_path, monkeypatch) -> None:
calls: list[dict] = []
monkeypatch.setattr(
hy_sdk_runtime, "_acquire_client", lambda profile, **kwargs: calls.append(kwargs)
)
profile = hy_sdk_runtime.builtin_retrieval_snapshot()
hy_sdk_runtime.acquire_client(profile, api_key=None, data_dir=tmp_path, mode="pro")
hy_sdk_runtime.acquire_client(
profile, api_key=None, data_dir=tmp_path, mode="pro", language="ja"
)
assert "language" not in calls[0]
assert calls[1]["language"] == "ja"
# --- the stored setting -----------------------------------------------------------
def test_the_setting_persists_and_an_untouched_install_writes_none(tmp_path) -> None:
store = ConfigStore(tmp_path)
store.set_sync("cursor", True)
assert "ui_language" not in json.loads((tmp_path / "config.json").read_text())
assert store.ui_language() is None
store.set_ui_language("ja")
assert json.loads((tmp_path / "config.json").read_text())["ui_language"] == "ja"
assert ConfigStore(tmp_path).ui_language() == "ja"
store.set_ui_language(None)
assert ConfigStore(tmp_path).ui_language() is None
with pytest.raises(ValueError, match="invalid_ui_language"):
store.set_ui_language("zh-CN") # type: ignore[arg-type]
with pytest.raises(ValidationError):
ConfigDocument.model_validate({"ui_language": "klingon"})
# --- rebuilding handles -------------------------------------------------------------
class _FakeHandle:
def __init__(self, language: str | None) -> None:
self.language = language
self.closed = False
async def client(self):
return self
async def close(self):
self.closed = True
def test_the_read_handle_rebuilds_its_client_when_the_language_changes(
monkeypatch, tmp_path
) -> None:
language: list[str | None] = [None]
made: list[_FakeHandle] = []
def handle_for(profile, *, api_key, data_dir, mode=None, features=(), language=None):
made.append(_FakeHandle(language))
return made[-1]
monkeypatch.setattr(sdk_read_handle, "SdkClientHandle", handle_for)
class _NoProfile:
def active_identity(self):
return None
reads = sdk_read_handle.ActiveProfileReadHandle(
profiles=_NoProfile(), # type: ignore[arg-type]
secrets=None, # type: ignore[arg-type]
data_dir=tmp_path,
memory_mode=lambda: "pro",
language=lambda: language[0],
)
async def scenario():
# A use lasts as long as the task that asked, so each read is a task.
old = await asyncio.create_task(reads.client())
assert old.language is None
assert await asyncio.create_task(reads.client()) is old
language[0] = "ja"
fresh = await asyncio.create_task(reads.client())
assert fresh is not old and fresh.language == "ja"
for _ in range(10):
await asyncio.sleep(0)
assert old.closed and not fresh.closed
await reads.close()
asyncio.run(scenario())
class _Profiles:
def active_identity(self):
return ("profile-a", 1)
def snapshot(self, profile_id, revision):
return SimpleNamespace(profile_id=profile_id, has_api_key=False)
def resolve_snapshot_secret(self, profile_id, revision, secrets):
return None
def test_a_language_change_keeps_a_busy_write_handle_until_its_write_ends(monkeypatch) -> None:
language: list[str | None] = [None]
opened: list[_FakeHandle] = []
def handle(profile, **kwargs):
opened.append(_FakeHandle(kwargs.get("language")))
return opened[-1]
monkeypatch.setattr(sdk_write_processor, "SdkClientHandle", handle)
coordinator = sdk_write_processor.SdkWriteCoordinator(
profiles=_Profiles(), # type: ignore[arg-type]
secrets=None, # type: ignore[arg-type]
data_dir=None,
transport_factory=lambda _profile: None,
sedimentation_enabled=lambda: True,
memory_mode=lambda: "pro",
sdk_language=lambda: language[0],
)
async def scenario():
async with coordinator.use_client() as client:
assert client.language is None
language[0] = "de"
await coordinator.retire_stale_handles()
# In flight: it finishes in the language it started with (#218).
assert not opened[0].closed
assert opened[0].closed
async with coordinator.use_client() as client:
assert client.language == "de"
language[0] = None
await coordinator.retire_stale_handles()
assert opened[1].closed
await coordinator.close()
asyncio.run(scenario())
# --- the HTTP surface -----------------------------------------------------------------
def run_app(tmp_path, monkeypatch, scenario) -> None:
async def main() -> None:
settings = Settings(
data_dir=tmp_path / "data",
source_home=tmp_path,
api_token=SecretStr(TOKEN),
imports_enabled=False,
automatic_memory_processing=False,
live_sync_enabled=False,
)
app = create_app(settings, secret_store=InMemorySecretStore())
@asynccontextmanager
async def write_client():
yield SimpleNamespace()
async with app.router.lifespan_context(app):
monkeypatch.setattr(app.state.sdk_write_coordinator, "use_client", write_client)
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
headers={"Authorization": f"Bearer {TOKEN}"},
) as client:
await scenario(app, client)
asyncio.run(main())
@pytest.mark.fresh_sdk
def test_ui_language_api_get_put_and_status(tmp_path, monkeypatch) -> None:
async def scenario(app, client):
reads = app.state.sdk_client_handle
writes = app.state.sdk_write_coordinator
retired: list[str | None] = []
retire = writes.retire_stale_handles
async def counting():
retired.append(writes.sdk_language())
await retire()
monkeypatch.setattr(writes, "retire_stale_handles", counting)
current = (await client.get("/api/v1/settings/ui-language")).json()
assert current["ui_language"] is None and current["sdk_language"] == "auto"
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.
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
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"]
# An explicit English choice forces English prompts.
body = (await client.put("/api/v1/settings/ui-language", json={"ui_language": "en"})).json()
assert body["sdk_language"] == "en" and body["rebuilt"] is True
processing = (await client.get("/api/v1/memory-processing")).json()
assert processing["pipeline"]["ui_language"] == "en"
assert processing["pipeline"]["sdk_language"] == "en"
# Back to "follow the 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
saved = json.loads((tmp_path / "data" / "config.json").read_text())
assert saved["ui_language"] is None
for bad in ({"ui_language": "zh-CN"}, {"ui_language": "xx"}, {"language": "ja"}, {}):
response = await client.put("/api/v1/settings/ui-language", json=bad)
assert response.status_code == 422, bad
run_app(tmp_path, monkeypatch, scenario)
+5 -5
View File
@@ -991,7 +991,7 @@ wheels = [
[[package]]
name = "hy-memory"
version = "2.0.10"
version = "2.0.11"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "chromadb" },
@@ -1008,9 +1008,9 @@ dependencies = [
{ name = "scikit-learn" },
{ name = "tiktoken" },
]
sdist = { url = "https://files.pythonhosted.org/packages/86/ed/1e1264d5b8741237c34fdaf17ae68bf5d16d403ca73c054997a502cd06a3/hy_memory-2.0.10.tar.gz", hash = "sha256:0841a441862e2427f75898bb8c958ed22fa083d2c0326298687ee129bcb729a8", size = 881659, upload-time = "2026-11-01T11:33:33.742Z" }
sdist = { url = "https://files.pythonhosted.org/packages/01/93/aec7d243259f6a315f38e8ebc21a9eea153a48fc13648fda149be7672788/hy_memory-2.0.11.tar.gz", hash = "sha256:96d6bd606d61052f127cd274aa6f8de8e7151d6250f1e89e4ef5b90e18ec7cb0", size = 1245738, upload-time = "2026-11-01T14:15:02.474Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/d6/c07a9d2d3af14da5a90c2bc466a46fc77068c24570fafe8f5729e46e2cae/hy_memory-2.0.10-py3-none-any.whl", hash = "sha256:54528d61a1730c7ea0f464f15144de3d1952f5d9aab1875bc841c97a193914b1", size = 947165, upload-time = "2026-11-01T11:33:31.66Z" },
{ url = "https://files.pythonhosted.org/packages/1a/85/1f5e6a13507a0cfcdce26f67c590df00d327a0f7d5c5b5483c3bd0fa2f85/hy_memory-2.0.11-py3-none-any.whl", hash = "sha256:c0173444a83c1197a49acaccb123f8286cea205b4838c1e83d106974f5bad259", size = 1315871, upload-time = "2026-11-01T14:14:58.764Z" },
]
[[package]]
@@ -1801,7 +1801,7 @@ wheels = [
[[package]]
name = "one-memory"
version = "1.0.5"
version = "1.0.6"
source = { editable = "." }
dependencies = [
{ name = "aiomysql" },
@@ -1837,7 +1837,7 @@ requires-dist = [
{ name = "fastapi", specifier = ">=0.141.1" },
{ name = "filelock", specifier = ">=3.32.4" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "hy-memory", specifier = "==2.0.10" },
{ name = "hy-memory", specifier = "==2.0.11" },
{ name = "keyring", specifier = ">=25.7.0" },
{ name = "numpy", specifier = "==2.5.2" },
{ name = "onnxruntime", specifier = "==1.23.2" },
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@one-memory/desktop",
"version": "1.0.5",
"version": "1.0.6",
"private": true,
"main": "dist/main.js",
"scripts": {
@@ -30,7 +30,7 @@ it("rebuilds translated labels from committed language updates without exporting
expect(buildMenu).toHaveBeenLastCalledWith(
expect.arrayContaining([expect.objectContaining({ label: "Ayuda" })]),
);
snapshot.locale = "zh-CN";
snapshot.locale = "zh-Hans";
change?.();
expect(read).toHaveBeenCalledTimes(2);
expect(buildMenu).toHaveBeenCalledTimes(2);
+2 -2
View File
@@ -16,14 +16,14 @@ export function installApplicationMenu(options: {
} catch {
options.reportError(
translate(
options.language?.read().locale ?? "zh-CN",
options.language?.read().locale ?? "zh-Hans",
"nativeExportError",
),
);
}
};
const refresh = () => {
const locale = options.language?.read().locale ?? "zh-CN";
const locale = options.language?.read().locale ?? "zh-Hans";
options.setMenu(
options.buildMenu([
{
@@ -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,9 +27,10 @@ function fixture(
status: "disabled",
};
let changed: (() => void) | undefined;
let hosting: RuntimeHosting | Error = "background-service";
const languageSnapshot: LanguageSnapshot = {
preference: "zh-CN",
locale: "zh-CN",
preference: "zh-Hans",
locale: "zh-Hans",
revision: 1,
};
const app = { isPackaged: packaged, quit: vi.fn() };
@@ -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;
},
};
}
@@ -121,7 +131,7 @@ describe("native macOS background tray", () => {
expect(f.setLoginPreference).not.toHaveBeenCalled();
f.click(0);
expect(f.open).toHaveBeenCalledOnce();
f.changeLanguage("zh-CN");
f.changeLanguage("zh-Hans");
await flush();
expect(f.menu()[0]?.label).toBe("打开 MemHub");
f.click(3);
@@ -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),
+18 -3
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;
@@ -44,7 +49,7 @@ export function installBackgroundTray(options: TrayOptions): () => void {
let generation = 0;
const refresh = async () => {
if (disposed) return;
const locale = options.language?.read().locale ?? "zh-CN";
const locale = options.language?.read().locale ?? "zh-Hans";
const t = (key: MessageKey) =>
translate(locale, key, { product: productName });
const current = ++generation;
@@ -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 },
@@ -82,7 +94,7 @@ export function installBackgroundTray(options: TrayOptions): () => void {
.catch(() =>
options.reportError(
translate(
options.language?.read().locale ?? "zh-CN",
options.language?.read().locale ?? "zh-Hans",
"nativeLoginError",
),
),
@@ -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();
},
@@ -0,0 +1,74 @@
import type { LanguageSnapshot } from "@one-memory/i18n";
import { describe, expect, it, vi } from "vitest";
import { coreUiLanguage, installCoreLanguageSync } from "./core-language-sync";
function source(initial: LanguageSnapshot) {
let snapshot = initial;
const listeners = new Set<(value: LanguageSnapshot) => void>();
return {
read: () => snapshot,
subscribe(listener: (value: LanguageSnapshot) => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
change(next: LanguageSnapshot) {
snapshot = next;
for (const listener of listeners) listener(next);
},
};
}
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("sends the stored choice once at launch, then every change", async () => {
const language = source({ preference: "es", locale: "es", revision: 0 });
const send = vi.fn(async () => {});
const dispose = installCoreLanguageSync({ language, send });
await settle();
expect(send.mock.calls).toEqual([["es"]]);
language.change({ preference: "ja", locale: "ja", revision: 1 });
await settle();
language.change({ preference: "system", locale: "en", revision: 2 });
await settle();
expect(send.mock.calls).toEqual([["es"], ["ja"], [null]]);
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 () => {
const language = source({
preference: "system",
locale: "ja",
revision: 0,
});
const send = vi.fn(async () => {});
installCoreLanguageSync({ language, send });
await settle();
expect(send.mock.calls).toEqual([[null]]);
});
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>>()
.mockRejectedValueOnce(new Error("core_unavailable"))
.mockResolvedValue(undefined);
const reportError = vi.fn();
installCoreLanguageSync({ language, send, reportError });
await settle();
expect(reportError).toHaveBeenCalledTimes(1);
language.change({ preference: "fr", locale: "fr", revision: 1 });
await settle();
expect(send.mock.calls).toEqual([["fr"], ["fr"]]);
});
});
@@ -0,0 +1,48 @@
import type { GuiLocale, LanguagePreference } 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;
}
/**
* Keep Core's `ui_language` setting on the display language a person chose.
*
* 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.
*/
export function installCoreLanguageSync(options: {
language: NativeLanguageSource;
send(uiLanguage: GuiLocale | null): Promise<void>;
reportError?(error: unknown): void;
}): () => void {
let closed = false;
let sent: GuiLocale | null | undefined;
let queue: Promise<void> = Promise.resolve();
const push = () => {
queue = queue.then(async () => {
if (closed) return;
const value = coreUiLanguage(options.language.read().preference);
if (value === sent) return;
try {
await options.send(value);
sent = value;
} catch (error) {
options.reportError?.(error);
}
});
};
const unsubscribe = options.language.subscribe(push);
push();
return () => {
closed = true;
unsubscribe();
};
}
@@ -23,8 +23,8 @@ const fake = vi.hoisted(() => ({
unload: undefined as unknown as EventTarget,
packaged: true,
userData: "",
preferred: vi.fn(() => ["es-MX", "zh-CN"]),
locale: vi.fn(() => "zh-CN"),
preferred: vi.fn(() => ["es-MX", "zh-Hans"]),
locale: vi.fn(() => "zh-Hans"),
menu: vi.fn(),
loadFailure: false,
reduceMotion: false as unknown,
@@ -301,10 +301,10 @@ describe("GUI language main IPC", () => {
await expect(read(eventFor(window))).rejects.toThrow();
await expect(write(eventFor(window), "es")).rejects.toThrow();
window.frame.url = "one-memory://app/index.html";
await write(eventFor(window), "zh-CN");
await write(eventFor(window), "zh-Hans");
expect(window.frame.send).toHaveBeenCalledWith(
"one-memory:language-preference-changed",
{ preference: "zh-CN", locale: "zh-CN", revision: 1 },
{ preference: "zh-Hans", locale: "zh-Hans", revision: 1 },
);
expect(foreign.frame.send).not.toHaveBeenCalled();
window.frame.send.mockImplementation(() => {
@@ -539,7 +539,7 @@ describe("language preference preload", () => {
"es",
);
fake.invoke.mockClear();
await expect(api.setLanguagePreference("fr")).rejects.toThrow();
await expect(api.setLanguagePreference("it")).rejects.toThrow();
expect(fake.invoke).not.toHaveBeenCalled();
const listener = vi.fn();
const unsubscribe = api.onLanguagePreferenceChanged(listener);
@@ -91,7 +91,7 @@ describe("early desktop startup errors", () => {
new Error("desktop_startup_failed"),
);
expect(showError).toHaveBeenCalledExactlyOnceWith(
translate(locale ?? "zh-CN", "nativeErrorTitle"),
translate(locale ?? "zh-Hans", "nativeErrorTitle"),
stableRuntimeUserMessage(new Error("desktop_startup_failed")),
);
expect(exit).toHaveBeenCalledExactlyOnceWith(1);
@@ -59,9 +59,9 @@ export function installEarlyStartupErrorHandler(
// The dialog is the only part of this a person ever reads, so it gets the
// explanation, not the code. The code stays in the diagnostic event,
// where support can still match it against a known failure.
let locale: GuiLocale = "zh-CN";
let locale: GuiLocale = "zh-Hans";
try {
locale = options.readLocale?.() ?? "zh-CN";
locale = options.readLocale?.() ?? "zh-Hans";
} catch {
// Preferences may not exist yet; do not read files or guess the locale.
}
@@ -33,7 +33,7 @@ describe("language preference IPC", () => {
if (!read || !write) throw Error("Missing language handlers");
await expect(read("foreign")).rejects.toThrow("untrusted");
await expect(write("iframe", "es")).rejects.toThrow("untrusted");
await expect(write("trusted", "fr")).rejects.toThrow();
await expect(write("trusted", "it")).rejects.toThrow();
await expect(write("trusted", "es", "extra")).rejects.toThrow();
expect(save).not.toHaveBeenCalled();
expect(await read("trusted")).toEqual({
@@ -29,23 +29,42 @@ describe("GUI language preferences", () => {
revision: 0,
});
expect(await readdir(root)).toEqual([]);
expect(await store.set("zh-CN")).toEqual({
preference: "zh-CN",
locale: "zh-CN",
expect(await store.set("zh-Hans")).toEqual({
preference: "zh-Hans",
locale: "zh-Hans",
revision: 1,
});
expect(
JSON.parse(await readFile(path.join(root, "gui-language.json"), "utf8")),
).toEqual({ preference: "zh-CN" });
).toEqual({ preference: "zh-Hans" });
expect((await LanguagePreferences.open(root, "es")).read().preference).toBe(
"zh-CN",
"zh-Hans",
);
expect(
(await LanguagePreferences.open(await profile(), "es")).read().preference,
).toBe("system");
expect(await readdir(root)).toEqual(["gui-language.json"]);
});
it.each(["{broken", '{"preference":"fr"}', "null", "[]"])(
it.each([
["zh-CN", "zh-Hans", "zh-Hans"],
["es", "es", "es"],
["en", "en", "en"],
["ja", "ja", "ja"],
["system", "system", "en"],
])(
"keeps an existing user's stored %s choice as %s",
async (stored, preference, locale) => {
const root = await profile();
const file = path.join(root, "gui-language.json");
const content = `${JSON.stringify({ preference: stored })}\n`;
await writeFile(file, content);
const store = await LanguagePreferences.open(root, "en-US");
expect(store.read()).toEqual({ preference, locale, revision: 0 });
// Read as today's code; the file is rewritten only by the next change.
expect(await readFile(file, "utf8")).toBe(content);
},
);
it.each(["{broken", '{"preference":"it"}', "null", "[]"])(
"does not overwrite invalid input %s while loading",
async (content) => {
const root = await profile();
@@ -69,7 +88,7 @@ describe("GUI language preferences", () => {
});
const listener = vi.fn();
store.subscribe(listener);
await expect(store.set("zh-CN")).rejects.toThrow(
await expect(store.set("zh-Hans")).rejects.toThrow(
"language_preference_save_failed",
);
expect(store.read()).toEqual({
@@ -78,7 +97,7 @@ describe("GUI language preferences", () => {
revision: 0,
});
expect(listener).not.toHaveBeenCalled();
await store.set("zh-CN");
await store.set("zh-Hans");
expect(store.read().revision).toBe(1);
expect(listener).toHaveBeenCalledTimes(1);
});
@@ -97,7 +116,7 @@ describe("GUI language preferences", () => {
});
const listener = vi.fn();
const unsubscribe = store.subscribe(listener);
const first = store.set("zh-CN");
const first = store.set("zh-Hans");
const second = store.set("es");
await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1));
expect(store.read().revision).toBe(0);
@@ -5,6 +5,7 @@ import {
isLanguagePreference,
type LanguagePreference,
type LanguageSnapshot,
normalizeLanguagePreference,
resolveGuiLocale,
} from "@one-memory/i18n";
@@ -58,14 +59,13 @@ export class LanguagePreferences {
let preference: LanguagePreference = "system";
try {
const data: unknown = JSON.parse(await io.read(file));
if (
data &&
typeof data === "object" &&
"preference" in data &&
isLanguagePreference(data.preference)
) {
preference = data.preference;
}
// An earlier build's code (zh-CN) reads as today's (zh-Hans), so a
// person keeps the language they chose; the next change writes it.
const stored =
data && typeof data === "object" && "preference" in data
? normalizeLanguagePreference(data.preference)
: null;
if (stored !== null) preference = stored;
} catch {
// Missing, unreadable or corrupt preferences never block startup or get rewritten.
}
@@ -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);
}
+181 -25
View File
@@ -23,7 +23,9 @@ import {
type HostPermissionClient,
installPackagedRuntime,
installUserService,
queryRuntimeServiceState,
RecallBrokerSupervisor,
type RuntimeServiceState,
RuntimeSupervisor,
readInstalledRuntimeSource,
readPublishedCorePort,
@@ -59,6 +61,7 @@ import { installApplicationMenu } from "./application-menu";
import { installBackgroundHealthIpc } from "./background-health-ipc";
import { installBackgroundTray } from "./background-tray";
import { installConnectionSetupIpc } from "./connection-setup-ipc";
import { installCoreLanguageSync } from "./core-language-sync";
import {
CoreSupervisor,
ensureRuntimeProcess,
@@ -109,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";
@@ -139,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;
@@ -151,6 +160,7 @@ let connectionSetup: ReturnType<typeof installConnectionSetupIpc> | null = null;
let disposeBackgroundHealth: (() => void) | null = null;
let stableRuntimeRepair: Promise<void> | null = null;
let disposeDiagnostics: (() => void) | null = null;
let disposeCoreLanguageSync: (() => void) | null = null;
let desktopDiagnostics: DiagnosticLogger | null = null;
let exportDiagnosticsFromMenu: (() => Promise<void>) | null = null;
const desktopStartupStartedAt = Date.now();
@@ -165,7 +175,7 @@ installEarlyStartupErrorHandler({
process.env.ONE_MEMORY_DATA_DIR?.trim() ||
path.join(app.getPath("home"), ".one-memory"),
stderr: process.stderr,
readLocale: () => languagePreferences?.read().locale ?? "zh-CN",
readLocale: () => languagePreferences?.read().locale ?? "zh-Hans",
showError: (title, message) => dialog.showErrorBox(title, message),
exit: (code) => app.exit(code),
});
@@ -245,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([
{
@@ -296,9 +325,9 @@ function installPackagedWebProtocol(): void {
installAppProtocol(path.join(process.resourcesPath, "web"), {
// Preferences are open by now: beforeNativeUi runs before this window.
startupScreen: () => {
let locale: GuiLocale = "zh-CN";
let locale: GuiLocale = "zh-Hans";
try {
locale = languagePreferences?.read().locale ?? "zh-CN";
locale = languagePreferences?.read().locale ?? "zh-Hans";
} catch {
// An unreadable preference keeps the Chinese placeholder.
}
@@ -496,6 +525,7 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
!preview &&
(hostIntegrationEnabled || desktopTestIsolation !== null) &&
installedApplication;
runtimeServiceManagedByApp = appManagedRuntime;
const runningPackagedHost =
app.isPackaged && !isolatedHourly
? await resolveRunningPackagedHostPaths({
@@ -849,14 +879,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" },
);
});
@@ -1167,7 +1208,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
@@ -1237,12 +1277,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({
@@ -1256,8 +1352,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?.(),
@@ -1289,12 +1390,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 &&
@@ -1404,16 +1503,16 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
void dialog.showMessageBox({
type: "warning",
message: translate(
languagePreferences?.read().locale ?? "zh-CN",
languagePreferences?.read().locale ?? "zh-Hans",
"nativeMoveTitle",
),
detail: translate(
languagePreferences?.read().locale ?? "zh-CN",
languagePreferences?.read().locale ?? "zh-Hans",
"nativeMoveDetail",
),
buttons: [
translate(
languagePreferences?.read().locale ?? "zh-CN",
languagePreferences?.read().locale ?? "zh-Hans",
"nativeAcknowledge",
),
],
@@ -1518,6 +1617,43 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
};
},
});
if (languagePreferences) {
// Core builds the SDK clients with the display language (`ui_language`).
disposeCoreLanguageSync = installCoreLanguageSync({
language: languagePreferences,
send: async (uiLanguage) => {
const core = coreSupervisor;
if (!core) throw new Error("core_supervisor_unavailable");
const publishedPort = await readPublishedCorePort(dataDir).catch(
() => null,
);
const response = await fetch(
`http://127.0.0.1:${publishedPort ?? core.port}/api/v1/settings/ui-language`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${core.apiToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ ui_language: uiLanguage }),
signal: AbortSignal.timeout(5_000),
},
);
if (!response.ok)
throw new Error(`ui_language_sync_http_${response.status}`);
},
reportError: (error) =>
void diagnostics
.log({
level: "warn",
component: "desktop.language",
event: "ui_language.sync_failed",
outcome: "failed",
...diagnosticError(error),
})
.catch(() => {}),
});
}
await diagnostics.log({
level: "info",
component: "desktop.lifecycle",
@@ -1626,7 +1762,8 @@ if (desktopVersion !== null) {
? readLoginPreference(
runtimeDataDir,
process.platform,
preserveManagedRuntimeOnQuit,
runtimeServiceManagedByApp,
currentRuntimeServiceState,
)
: Promise.resolve({
supported: false,
@@ -1639,9 +1776,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() }
: {}),
@@ -1657,6 +1806,8 @@ if (desktopVersion !== null) {
...(coreSupervisor ? { instance_id: coreSupervisor.instanceId } : {}),
});
// An admitted native effect must settle and journal before Runtime detach.
disposeCoreLanguageSync?.();
disposeCoreLanguageSync = null;
disposeLanguagePreferences?.();
disposeLanguagePreferences = null;
await languagePreferences?.drain();
@@ -1671,7 +1822,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(),
@@ -1708,6 +1862,8 @@ if (desktopVersion !== null) {
});
},
dispose: () => {
disposeCoreLanguageSync?.();
disposeCoreLanguageSync = null;
disposeLanguagePreferences?.();
disposeLanguagePreferences = null;
disposeApplicationMenu?.();
@@ -1738,7 +1894,7 @@ if (desktopVersion !== null) {
showMigrationFailureDialog({
error,
dataDir: runtimeDataDir,
locale: languagePreferences?.read().locale ?? "zh-CN",
locale: languagePreferences?.read().locale ?? "zh-Hans",
dependencies: {
showMessageBoxSync: (options) => dialog.showMessageBoxSync(options),
showItemInFolder: (target) => shell.showItemInFolder(target),
@@ -1766,7 +1922,7 @@ if (desktopVersion !== null) {
return;
dialog.showErrorBox(
translate(
languagePreferences?.read().locale ?? "zh-CN",
languagePreferences?.read().locale ?? "zh-Hans",
"nativeErrorTitle",
),
stableRuntimeFailureDetail(error),
@@ -7,6 +7,7 @@ import {
MAX_ISSUE_URL_LENGTH,
MIGRATION_ISSUE_URL,
type MigrationDialogDependencies,
migrationDialogText,
migrationFailureCode,
migrationIssueBody,
migrationIssueUrl,
@@ -41,7 +42,7 @@ describe("migration failure dialog", () => {
const handled = showMigrationFailureDialog({
error: new Error("sdk_migration_preflight_failed"),
dataDir,
locale: "zh-CN",
locale: "zh-Hans",
dependencies: deps,
});
@@ -176,7 +177,7 @@ describe("migration failure dialog", () => {
// Desktop's own migration throws the code the note records.
error: new Error("sdk_data_plane_dispatch_failed"),
dataDir,
locale: "zh-CN",
locale: "zh-Hans",
dependencies: deps,
environment: {
appVersion: "2.0.6",
@@ -217,6 +218,35 @@ describe("migration failure dialog", () => {
expect(body).not.toContain(accountHome);
});
it("speaks the display language in the dialog and the issue body", () => {
const en = migrationDialogText("sdk_migration_x", "/tmp/m.log", "en", true);
expect(en.detail).toBe(
"To protect your data, MemHub will not start. Please attach the migration log to a GitHub issue and send it to us. (Error code: sdk_migration_x)" +
"\n\nYour data was restored to its state before the upgrade." +
"\n\nLog: /tmp/m.log",
);
const ja = migrationDialogText("sdk_migration_x", "/tmp/m.log", "ja");
expect(ja.message).toBe(
"MemHub のアップグレード中にデータ移行が完了しませんでした。",
);
expect([ja.openLog, ja.quit]).toEqual(["ログを開く", "終了"]);
expect(ja.detail).toContain("sdk_migration_x");
// A system language code resolves like the GUI does (de-AT → de).
expect(migrationDialogText("c", "/l", "de-AT").quit).toBe("Beenden");
const body = migrationIssueBody({
code: "sdk_migration_preflight_failed",
logPath: "~/.one-memory/logs/sdk-migration.log",
dataRestored: false,
arch: "arm64",
locale: "fr",
});
expect(body).toContain("## Échec de la migration lors de la mise à jour");
expect(body).toMatch(/- Code d’erreur\s: sdk_migration_preflight_failed/);
expect(body).toContain(": non");
expect(body).toMatch(/- Système\s: inconnu \(arm64\)/);
expect(body).toContain("~/.one-memory/logs/sdk-migration.log");
});
it("says 未知 for what the log does not know instead of guessing", () => {
const body = migrationIssueBody({
code: "sdk_migration_preflight_failed",
@@ -296,7 +326,7 @@ describe("migration failure dialog", () => {
showMigrationFailureDialog({
error: new Error("installed_version_conflict"),
dataDir: await home(),
locale: "zh-CN",
locale: "zh-Hans",
dependencies: deps,
}),
).toBe(false);
@@ -8,6 +8,12 @@ import {
readSync,
} from "node:fs";
import path from "node:path";
import {
type GuiLocale,
isGuiLocale,
resolveGuiLocale,
translate,
} from "@one-memory/i18n";
/**
* What a person sees when an upgrade did not reach SDK authority.
@@ -106,51 +112,70 @@ export interface MigrationIssueFacts {
arch?: string | null;
/** The local log path, already shown with the home directory as `~`. */
logPath: string;
/** The display language; Simplified Chinese when absent. */
locale?: string;
}
const UNKNOWN = "未知";
function versionText(value: string | null | undefined): string {
return typeof value === "string" && VERSION.test(value) ? value : UNKNOWN;
/** A display language, or the one a system language code resolves to. */
function dialogLocale(locale: string | undefined): GuiLocale {
if (locale === undefined) return "zh-Hans";
return isGuiLocale(locale) ? locale : resolveGuiLocale("system", locale);
}
function buildText(value: number | null | undefined): string {
function versionText(value: string | null | undefined): string | null {
return typeof value === "string" && VERSION.test(value) ? value : null;
}
function buildText(value: number | null | undefined): string | null {
return typeof value === "number" && Number.isSafeInteger(value) && value > 0
? String(value)
: UNKNOWN;
: null;
}
function stepText(
step: string | null | undefined,
sub: string | null | undefined,
) {
if (typeof step !== "string" || !STEP.test(step)) return UNKNOWN;
): string | null {
if (typeof step !== "string" || !STEP.test(step)) return null;
return typeof sub === "string" && STEP.test(sub) ? `${step} / ${sub}` : step;
}
/** The issue body: short, content-free, and honest about what is unknown. */
export function migrationIssueBody(facts: MigrationIssueFacts): string {
const locale = dialogLocale(facts.locale);
const unknown = translate(locale, "migration.unknown");
const known = (value: string | null) => value ?? unknown;
const restored =
facts.dataRestored === true
? "是"
? translate(locale, "migration.yes")
: facts.dataRestored === false
? "否"
: UNKNOWN;
? translate(locale, "migration.no")
: unknown;
const system = [facts.osName, facts.osVersion]
.map((part) => versionText(part))
.filter((part) => part !== UNKNOWN)
.filter((part) => part !== null)
.join(" ");
const arch = versionText(facts.arch);
return [
"## 升级迁移失败",
`- 从 MemHub ${versionText(facts.fromVersion)}(build ${buildText(facts.fromBuild)})升级到 ${versionText(facts.toVersion)}(build ${buildText(facts.toBuild)}),迁移数据失败。`,
`- 错误码:${CODE.test(facts.code) ? facts.code : UNKNOWN}`,
`- 失败步骤:${stepText(facts.step, facts.subStep)}`,
`- 数据已恢复到升级前状态:${restored}`,
`- 系统:${system || UNKNOWN}${arch === UNKNOWN ? "" : `(${arch})`}`,
`- 迁移日志(本地):${facts.logPath}`,
translate(locale, "migration.issueHeading"),
translate(locale, "migration.issueUpgrade", {
fromVersion: known(versionText(facts.fromVersion)),
fromBuild: known(buildText(facts.fromBuild)),
toVersion: known(versionText(facts.toVersion)),
toBuild: known(buildText(facts.toBuild)),
}),
translate(locale, "migration.issueCode", {
code: CODE.test(facts.code) ? facts.code : unknown,
}),
translate(locale, "migration.issueStep", {
step: known(stepText(facts.step, facts.subStep)),
}),
translate(locale, "migration.issueRestored", { restored }),
translate(locale, "migration.issueSystem", { system: system || unknown }) +
(arch === null ? "" : translate(locale, "migration.issueArch", { arch })),
translate(locale, "migration.issueLog", { path: facts.logPath }),
"",
"请把上面这个日志文件拖到这里作为附件,再提交。",
translate(locale, "migration.issueAttach"),
"",
].join("\n");
}
@@ -302,31 +327,18 @@ export function migrationDialogText(
locale: string,
dataRestored = false,
): MigrationDialogText {
if (locale.toLowerCase().startsWith("en")) {
return {
title: "MemHub",
message: "The data migration for this MemHub upgrade did not finish.",
detail:
"To protect your data, MemHub will not start. Please attach the migration log to a GitHub issue and send it to us. " +
`(Error code: ${code})` +
(dataRestored
? "\n\nYour data was restored to its state before the upgrade."
: "") +
`\n\nLog: ${logPath}`,
openLog: "Open log",
quit: "Quit",
};
}
const language = dialogLocale(locale);
return {
title: "MemHub",
message: "MemHub 升级时数据迁移没有完成。",
message: translate(language, "migration.dialogMessage"),
detail:
"为保护你的数据,MemHub 不会启动。请把迁移日志附在 GitHub 反馈里发给我们。" +
`(错误码:${code})` +
(dataRestored ? "\n\n记忆数据已恢复到升级前的状态。" : "") +
`\n\n日志:${logPath}`,
openLog: "打开日志",
quit: "退出",
translate(language, "migration.dialogDetail", { code }) +
(dataRestored
? `\n\n${translate(language, "migration.dataRestored")}`
: "") +
`\n\n${translate(language, "migration.logPath", { path: logPath })}`,
openLog: translate(language, "migration.openLog"),
quit: translate(language, "migration.quit"),
};
}
@@ -434,6 +446,7 @@ export function showMigrationFailureDialog(options: {
osVersion: environment.osVersion ?? null,
arch: environment.arch ?? null,
logPath: displayPath(logPath, environment.home ?? null),
locale: options.locale,
});
void Promise.resolve(
options.dependencies.openExternal(migrationIssueUrl(code, body)),
@@ -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 启动失败。请重新启动;如果仍然打不开,请重新安装,你的记忆数据不会受到影响。",
};
@@ -12,7 +12,7 @@ describe("startup screen", () => {
expect(html).not.toContain("ipcRenderer");
});
it("speaks the interface language and keeps Chinese by default", () => {
expect(startupScreenHtml()).toContain('<html lang="zh-CN">');
expect(startupScreenHtml()).toContain('<html lang="zh-Hans">');
expect(startupScreenHtml()).toContain("正在启动,正在准备记忆服务…");
const spanish = startupScreenHtml("es");
expect(spanish).toContain('<html lang="es">');
+1 -1
View File
@@ -11,7 +11,7 @@ export const STARTUP_SCREEN_URL = `${APP_ORIGIN}${STARTUP_SCREEN_PATH}`;
* the gap. It deliberately makes no IPC calls: it renders before `initialize`
* has registered any handler, and anything it asked for would fail.
*/
export function startupScreenHtml(locale: GuiLocale = "zh-CN"): string {
export function startupScreenHtml(locale: GuiLocale = "zh-Hans"): string {
return `<!doctype html>
<html lang="${locale}">
<head>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@one-memory/site",
"version": "1.0.5",
"version": "1.0.6",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@one-memory/web",
"version": "1.0.5",
"version": "1.0.6",
"private": true,
"type": "module",
"scripts": {
@@ -27,7 +27,7 @@ it("translates application detail live without triggering extra reads or writes"
).toBeTruthy();
expect(screen.getByRole("switch", { name: "Sincronización" })).toBeTruthy();
await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1));
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-Hans");
await act(async () =>
window.dispatchEvent(
new StorageEvent("storage", { key: LANGUAGE_STORAGE_KEY }),
@@ -603,7 +603,7 @@ it("shows insertion still on when neither removal nor revocation succeeded", asy
});
let languageListener: ((snapshot: unknown) => void) | undefined;
function switchLanguage(locale: "es" | "zh-CN", revision: number) {
function switchLanguage(locale: "es" | "zh-Hans", revision: number) {
act(() => languageListener?.({ preference: locale, locale, revision }));
}
async function failDisconnectInSpanish() {
@@ -660,7 +660,7 @@ it("re-words a visible failure when the language changes, without new writes", a
).getRecallHosts;
const readCount = reads.mock.calls.length;
const revokes = bridge.revokeRecallPermission.mock.calls.length;
switchLanguage("zh-CN", 2);
switchLanguage("zh-Hans", 2);
expect(
await screen.findByText(
"连接未能移除,记忆权限也未能撤销,OpenCode 仍可使用记忆,请重试。原因:应用控制通道暂不可用。",
@@ -23,8 +23,8 @@ import { queryClient, resetClient } from "./query-client";
it("localizes the whole home live without repeating reads or changing imported text", async () => {
let change:
| ((snapshot: {
preference: "es" | "zh-CN";
locale: "es" | "zh-CN";
preference: "es" | "zh-Hans";
locale: "es" | "zh-Hans";
revision: number;
}) => void)
| undefined;
@@ -52,7 +52,9 @@ it("localizes the whole home live without repeating reads or changing imported t
fireEvent.click(
screen.getByRole("button", { name: "Ver las 5 aplicaciones" }),
);
act(() => change?.({ preference: "zh-CN", locale: "zh-CN", revision: 2 }));
act(() =>
change?.({ preference: "zh-Hans", locale: "zh-Hans", revision: 2 }),
);
expect(
screen.getByRole("heading", { name: "全部应用", level: 1 }),
).toBeTruthy();
+1 -1
View File
@@ -166,7 +166,7 @@ it("traduce el historial en vivo sin cambiar nombres ni contenido importado", as
).toBeTruthy();
expect(screen.getAllByText("Consulta completada")).toHaveLength(2);
expect(screen.getByText("Consulta de memorias")).toBeTruthy();
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-Hans");
fireEvent(window, new StorageEvent("storage", { key: LANGUAGE_STORAGE_KEY }));
expect(screen.getByRole("heading", { name: "这次查记忆" })).toBeTruthy();
window.localStorage.removeItem(LANGUAGE_STORAGE_KEY);
+1 -1
View File
@@ -205,7 +205,7 @@ function ConnectionMaterialFallback({
? t("connectionFallback.promptShell", { shell: material.shell })
: "";
const prompt =
locale === "zh-CN"
locale === "zh-Hans"
? material.prompt
: t("connectionFallback.promptTemplate", {
mcp: material.mcp,
+1 -1
View File
@@ -111,7 +111,7 @@ afterEach(() => {
function SwitchLanguage() {
const { setPreference } = useGuiLanguage();
return (
<button type="button" onClick={() => void setPreference("zh-CN")}>
<button type="button" onClick={() => void setPreference("zh-Hans")}>
Cambiar idioma
</button>
);
@@ -22,7 +22,7 @@ import {
function LanguageSwitch() {
const { setPreference } = useGuiLanguage();
return (
<button type="button" onClick={() => void setPreference("zh-CN")}>
<button type="button" onClick={() => void setPreference("zh-Hans")}>
Cambiar idioma
</button>
);
+1 -1
View File
@@ -33,7 +33,7 @@ vi.mock("./api", () => ({
function HealthLanguageSwitch() {
const { setPreference } = useGuiLanguage();
return (
<button type="button" onClick={() => void setPreference("zh-CN")}>
<button type="button" onClick={() => void setPreference("zh-Hans")}>
Cambiar idioma
</button>
);
@@ -95,7 +95,7 @@ describe("ImportProgressStrip", () => {
function Switch() {
const { setPreference } = useGuiLanguage();
return (
<button type="button" onClick={() => void setPreference("zh-CN")}>
<button type="button" onClick={() => void setPreference("zh-Hans")}>
Cambiar idioma
</button>
);
+36 -30
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { translate } from "@one-memory/i18n";
import { LOCALE_NAMES, SUPPORTED_LOCALES, translate } from "@one-memory/i18n";
import {
cleanup,
fireEvent,
@@ -20,7 +20,7 @@ afterEach(() => {
vi.restoreAllMocks();
});
it.each(["zh-CN", "en", "es"] as const)(
it.each(["zh-Hans", "en", "es", "ja", "de", "ru"] as const)(
"hides the compact label while preserving its accessible name in %s",
async (locale) => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
@@ -42,7 +42,7 @@ it.each(["zh-CN", "en", "es"] as const)(
},
);
it.each(["zh-CN", "en", "es"] as const)(
it.each(["zh-Hans", "en", "es", "zh-Hant", "fr", "ko"] as const)(
"keeps the normal settings label visible and accessible in %s",
(locale) => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
@@ -66,7 +66,7 @@ it.each(["zh-CN", "en", "es"] as const)(
},
);
it.each(["system", "zh-CN", "en", "es"] as const)(
it.each(["system", "zh-Hans", "en", "es", "pt", "id"] as const)(
"keeps one stable settings row for the %s preference",
async (preference) => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, preference);
@@ -81,40 +81,46 @@ it.each(["system", "zh-CN", "en", "es"] as const)(
expect(row).toBeTruthy();
expect(row?.querySelector("label")?.hidden).toBe(false);
await userEvent.click(selector);
expect(
screen
.getByRole("option", {
name: /简体中文|Simplified Chinese|Chino simplificado/,
})
.getAttribute("lang"),
).toBe("zh-CN");
expect(
screen
.getByRole("option", { name: /英语|English|Inglés/ })
.getAttribute("lang"),
).toBe("en");
expect(
screen
.getByRole("option", { name: /西班牙语|Spanish|Español/ })
.getAttribute("lang"),
).toBe("es");
// Every language by its own name, whatever the interface language.
for (const locale of SUPPORTED_LOCALES) {
expect(
screen
.getByRole("option", { name: LOCALE_NAMES[locale] })
.getAttribute("lang"),
).toBe(locale);
}
},
);
it("offers an accessible selector, persists and updates its label", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-Hans");
render(
<LanguageProvider>
<LanguageSelector />
</LanguageProvider>,
);
const selector = screen.getByRole("combobox", {
name: translate("zh-CN", "languageLabel"),
name: translate("zh-Hans", "languageLabel"),
});
expect(selector.getAttribute("aria-describedby")).toBeTruthy();
await userEvent.click(selector);
expect(screen.getAllByRole("option")).toHaveLength(4);
await userEvent.click(screen.getByRole("option", { name: "西班牙语" }));
expect(
screen.getAllByRole("option").map((option) => option.textContent),
).toEqual([
translate("zh-Hans", "languageSystem"),
"English",
"简体中文",
"繁體中文",
"日本語",
"Español",
"Deutsch",
"Português",
"Français",
"한국어",
"Русский",
"Bahasa Indonesia",
]);
await userEvent.click(screen.getByRole("option", { name: "Español" }));
expect(localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe("es");
expect(
screen.getByRole("combobox", { name: translate("es", "languageLabel") }),
@@ -143,12 +149,12 @@ it("supports keyboard selection without using the system select menu", async ()
expect(selector.getAttribute("aria-expanded")).toBe("true");
await userEvent.keyboard("{Escape}");
await userEvent.keyboard("{ArrowDown}{ArrowDown}{Enter}");
expect(localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe("zh-CN");
expect(localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe("en");
expect(selector.getAttribute("aria-expanded")).toBe("false");
});
it("announces storage errors without replacing the confirmed choice", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-Hans");
render(
<LanguageProvider>
<LanguageSelector />
@@ -158,12 +164,12 @@ it("announces storage errors without replacing the confirmed choice", async () =
throw new Error("denied");
});
await userEvent.click(screen.getByRole("combobox"));
await userEvent.click(screen.getByRole("option", { name: "西班牙语" }));
await userEvent.click(screen.getByRole("option", { name: "Español" }));
expect(screen.getByRole("alert").textContent).toBe(
translate("zh-CN", "languageSaveError"),
translate("zh-Hans", "languageSaveError"),
);
expect((screen.getByRole("combobox") as HTMLButtonElement).value).toBe(
"zh-CN",
"zh-Hans",
);
});
+12 -17
View File
@@ -1,4 +1,8 @@
import { isLanguagePreference } from "@one-memory/i18n";
import {
isLanguagePreference,
LOCALE_NAMES,
SUPPORTED_LOCALES,
} from "@one-memory/i18n";
import { useId, useState } from "react";
import { useGuiLanguage } from "./gui-language";
import { OptionMenu } from "./OptionMenu";
@@ -7,24 +11,15 @@ export function LanguageSelector({ compact = false }: { compact?: boolean }) {
const id = useId();
const { preference, setPreference, t, error } = useGuiLanguage();
const [pending, setPending] = useState(false);
// Each language by its own name, so a person can find theirs in any UI.
const options = [
{ value: "system", label: t("languageSystem") },
{
value: "zh-CN",
label: compact ? "简体中文" : t("languageChinese"),
lang: "zh-CN",
},
{
value: "en",
label: compact ? "English" : t("languageEnglish"),
lang: "en",
},
{
value: "es",
label: compact ? "Español" : t("languageSpanish"),
lang: "es",
},
] as const;
...SUPPORTED_LOCALES.map((locale) => ({
value: locale,
label: LOCALE_NAMES[locale],
lang: locale,
})),
];
return (
<section
aria-labelledby={compact ? `${id}-label` : `${id}-title`}
+2 -2
View File
@@ -114,7 +114,7 @@ type Props = {
function dayStamp(
value: string | null | undefined,
locale: GuiLocale = "zh-CN",
locale: GuiLocale = "zh-Hans",
) {
if (!value || !Number.isFinite(Date.parse(value)))
return memoryText(locale, "时间未知");
@@ -146,7 +146,7 @@ function monthDay(value: string, locale: GuiLocale): string | null {
function sourceStamp(
time: { at: string; basis: "conversation" | "import" | "write" } | undefined,
fallback: string | null | undefined,
locale: GuiLocale = "zh-CN",
locale: GuiLocale = "zh-Hans",
): string {
const day = time ? monthDay(time.at, locale) : null;
if (time && day)
+1 -1
View File
@@ -28,7 +28,7 @@ it("translates memory filters and detail affordances live without refetching", a
expect(screen.getByRole("button", { name: "Buscar" })).toBeTruthy();
const calls = reads.sdkLibrary.mock.calls.length;
act(() => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-Hans");
window.dispatchEvent(
new StorageEvent("storage", { key: LANGUAGE_STORAGE_KEY }),
);
+1 -1
View File
@@ -41,7 +41,7 @@ it("keeps the edited model draft and does not mutate on live language changes",
activate.mock.calls.length,
probeProfile.mock.calls.length,
];
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-Hans");
await act(async () =>
window.dispatchEvent(
new StorageEvent("storage", { key: LANGUAGE_STORAGE_KEY }),
@@ -77,7 +77,7 @@ function controller(overrides: Partial<SourceResponse> = {}) {
function SwitchLanguage() {
const { setPreference } = useGuiLanguage();
return (
<button type="button" onClick={() => void setPreference("zh-CN")}>
<button type="button" onClick={() => void setPreference("zh-Hans")}>
中文
</button>
);
@@ -100,7 +100,7 @@ afterEach(() => {
});
it("changes the first-screen language without losing source choices or starting an import", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-Hans");
const imports = controller();
const finish = vi.fn();
const user = userEvent.setup();
@@ -330,7 +330,7 @@ it.each([
);
},
);
it.each(["es", "zh-CN"] as const)(
it.each(["es", "zh-Hans"] as const)(
"preserves three multiline progress cards and receipt statuses in %s",
(locale) => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
@@ -475,7 +475,7 @@ it.each([
} as unknown as ImportJob;
for (const [locale, expected] of [
["es", spanish],
["zh-CN", chinese],
["zh-Hans", chinese],
] as const) {
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
const view = render(
@@ -20,7 +20,7 @@ afterEach(() => {
localStorage.clear();
});
it.each(["es", "zh-CN"] as const)(
it.each(["es", "zh-Hans"] as const)(
"keeps %s footer actions, pending locks and permission boundaries",
async (locale) => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
+1 -1
View File
@@ -78,7 +78,7 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
// The Chinese label already names the destination; shorter translated labels
// need it as a description for assistive technology.
const skipDestination =
locale === "zh-CN" ? "" : t("onboardingModelSkipDestination");
locale === "zh-Hans" ? "" : t("onboardingModelSkipDestination");
const fields = useRef<HTMLElement>(null);
const advancedToggle = useRef<HTMLButtonElement>(null);
const restoreToggleFocus = useRef(false);
+22 -2
View File
@@ -87,6 +87,26 @@ it("removes the duplicate native drag overlay above the language selector", () =
).toMatch(/-webkit-app-region:\s*no-drag/);
});
it("keeps long language names on one line in the fixed-size selectors", () => {
// "Utiliser la langue du système", "Systemsprache verwenden" and
// "Bahasa Indonesia" are wider than the 144px title-bar button.
for (const [file, selector] of [
[
"onboarding-r6.css",
".onboarding-r6-titlebar .r6-language-option-menu .om-option-menu-button",
],
[
"r6-settings.css",
".r6-settings .r6-language-option-menu .om-option-menu-button",
],
] as const) {
const rule = declaration(file, selector);
expect(rule, selector).toMatch(/white-space:\s*nowrap/);
expect(rule, selector).toMatch(/text-overflow:\s*ellipsis/);
expect(rule, selector).toMatch(/overflow:\s*hidden/);
}
});
it("keeps selection status outside decorative fades and readable when disabled", () => {
expect(
declaration("onboarding-r6.css", ".onboarding-r6-list-area::after"),
@@ -122,7 +142,7 @@ it("keeps the product language menu keyboard-focusable and switches both languag
expect(screen.getByRole("combobox").getAttribute("id")).toBe(selector.id);
await user.click(selector);
await user.click(screen.getByRole("option", { name: "简体中文" }));
expect(document.documentElement.lang).toBe("zh-CN");
expect(document.documentElement.lang).toBe("zh-Hans");
expect(screen.getByRole("combobox", { name: "界面语言" })).toBe(selector);
});
@@ -146,7 +166,7 @@ it.each([1, 2, 3] as const)(
await userEvent.click(selector);
expect(
screen.getByRole("option", { name: "简体中文" }).getAttribute("lang"),
).toBe("zh-CN");
).toBe("zh-Hans");
expect(
screen.getByRole("option", { name: "Español" }).getAttribute("lang"),
).toBe("es");
+2 -2
View File
@@ -28,7 +28,7 @@ it("changes settings copy live without rereading profiles or changing permission
).toBeNull();
const reads = vi.mocked(getModelProfiles).mock.calls.length;
await user.click(screen.getByRole("combobox"));
await user.click(screen.getByRole("option", { name: "Chino simplificado" }));
await user.click(screen.getByRole("option", { name: "简体中文" }));
expect(await screen.findByRole("heading", { name: "记忆模型" })).toBeTruthy();
expect(vi.mocked(getModelProfiles).mock.calls).toHaveLength(reads);
});
@@ -99,7 +99,7 @@ it("shows the help and diagnostics group by default and hides it when opted out"
const view = render(<R6Settings onDiagnostics={vi.fn()} />);
expect(screen.getByRole("combobox")).toBeTruthy();
await user.click(screen.getByRole("combobox"));
expect(screen.getAllByRole("option")).toHaveLength(4);
expect(screen.getAllByRole("option")).toHaveLength(12);
await user.keyboard("{Escape}");
await user.click(screen.getByRole("button", { name: "连接其他应用" }));
expect(screen.getByTestId("connection-default").textContent).toBe("true");
+14
View File
@@ -1025,6 +1025,20 @@ export type SedimentationState = {
imports_unaffected: boolean;
};
/**
* Tell Core the display language (null = follow the system). zh-Hans and
* null leave the SDK's zh/en detection; any other code sets its prompts.
*/
export async function setUiLanguage(
uiLanguage: string | null,
): Promise<{ ui_language: string | null; sdk_language: string }> {
const response = await client.put<{
ui_language: string | null;
sdk_language: string;
}>("/api/v1/settings/ui-language", { ui_language: uiLanguage });
return response.data;
}
export async function getSedimentation(): Promise<SedimentationState> {
const response = await client.get<SedimentationState>(
"/api/v1/sedimentation",
+10 -7
View File
@@ -65,7 +65,7 @@ export const CALL_HOSTS: Record<string, string> = {
pi: "Pi",
"deepseek-harness": "DeepSeek Harness",
};
export function callHostName(id: string | null, locale: GuiLocale = "zh-CN") {
export function callHostName(id: string | null, locale: GuiLocale = "zh-Hans") {
return id === null
? translate(locale, "calls.directHost")
: Object.hasOwn(CALL_HOSTS, id)
@@ -82,7 +82,7 @@ const HEADLINES = {
export function callEventHeadline(
item: CallEvent,
host?: string,
locale: GuiLocale = "zh-CN",
locale: GuiLocale = "zh-Hans",
) {
const name = host ?? callHostName(item.host_id, locale);
const fixed = HEADLINES[item.state as keyof typeof HEADLINES];
@@ -93,10 +93,13 @@ export function callEventHeadline(
count: plainCount(locale, count),
});
}
export function callRowHeadline(item: CallEvent, locale: GuiLocale = "zh-CN") {
export function callRowHeadline(
item: CallEvent,
locale: GuiLocale = "zh-Hans",
) {
return callEventHeadline(item, undefined, locale);
}
export function callRowDetail(item: CallEvent, locale: GuiLocale = "zh-CN") {
export function callRowDetail(item: CallEvent, locale: GuiLocale = "zh-Hans") {
const host = callHostName(item.host_id, locale);
const key: MessageKey =
item.state === "timed_out"
@@ -121,13 +124,13 @@ export function callRowDetail(item: CallEvent, locale: GuiLocale = "zh-CN") {
export function callDetailHeadline(
item: CallEvent,
host?: string,
locale: GuiLocale = "zh-CN",
locale: GuiLocale = "zh-Hans",
) {
return callEventHeadline(item, host, locale);
}
export function callDeliveryLabel(
item: CallEvent,
locale: GuiLocale = "zh-CN",
locale: GuiLocale = "zh-Hans",
) {
if (item.delivery === "delivered")
return translate(locale, "calls.detail.delivered", {
@@ -139,7 +142,7 @@ export function callDeliveryLabel(
}
export function callReturnDeliveryLabel(
item: CallEvent,
locale: GuiLocale = "zh-CN",
locale: GuiLocale = "zh-Hans",
) {
if (
item.state === "timed_out" ||
+6 -6
View File
@@ -22,7 +22,7 @@ describe("clockStamp", () => {
"reports an unknown time for %j",
(value) => {
expect(clockStamp(value)).toBe("时间未知");
expect(clockStamp(value, "zh-CN")).toBe("时间未知");
expect(clockStamp(value, "zh-Hans")).toBe("时间未知");
expect(clockStamp(value, "es")).toBe("Hora desconocida");
},
);
@@ -31,7 +31,7 @@ describe("clockStamp", () => {
// 12:05 UTC is 09:05 local on the same day.
const value = "2026-09-20T12:05:00Z";
expect(clockStamp(value, "es")).toBe("Hoy 09:05");
expect(clockStamp(value, "zh-CN")).toBe("今天 09:05");
expect(clockStamp(value, "zh-Hans")).toBe("今天 09:05");
});
it("defaults to Chinese", () => {
@@ -42,7 +42,7 @@ describe("clockStamp", () => {
// 02:00 UTC on the 20th is still the 19th in Buenos Aires.
const value = "2026-09-20T02:00:00Z";
expect(clockStamp(value, "es")).not.toMatch(/^Hoy/);
expect(clockStamp(value, "zh-CN")).not.toMatch(/^今天/);
expect(clockStamp(value, "zh-Hans")).not.toMatch(/^今天/);
expect(clockStamp(value, "es")).toBe(
new Intl.DateTimeFormat("es", {
month: "2-digit",
@@ -55,15 +55,15 @@ describe("clockStamp", () => {
});
it("names the year of a date in another year", () => {
expect(clockStamp("2254-01-13T13:54:00Z", "zh-CN")).toContain("2254");
expect(clockStamp("2254-01-13T13:54:00Z", "zh-Hans")).toContain("2254");
expect(clockStamp("2025-12-31T12:00:00Z", "es")).toContain("2025");
expect(clockStamp("2026-08-03T15:07:00Z", "zh-CN")).not.toContain("2026");
expect(clockStamp("2026-08-03T15:07:00Z", "zh-Hans")).not.toContain("2026");
});
it("formats another date with month and day, without a today word", () => {
const value = "2026-08-03T15:07:00Z";
const es = clockStamp(value, "es");
const zh = clockStamp(value, "zh-CN");
const zh = clockStamp(value, "zh-Hans");
// Spanish short date is day/month: 3 August. Padding varies across ICU
// versions, so only the order is pinned.
expect(es).toMatch(/^0?3\/0?8\b/);
+1 -1
View File
@@ -18,7 +18,7 @@ import { type GuiLocale, translate } from "@one-memory/i18n";
*/
export function clockStamp(
value: string | null | undefined,
locale: GuiLocale = "zh-CN",
locale: GuiLocale = "zh-Hans",
): string {
if (!value || !Number.isFinite(Date.parse(value)))
return translate(locale, "clock.unknown");
+1 -1
View File
@@ -9,7 +9,7 @@ export type DiagnosticsExportResult = {
};
export async function exportDiagnosticsFile(
locale: GuiLocale = "zh-CN",
locale: GuiLocale = "zh-Hans",
): Promise<DiagnosticsExportResult> {
if (!window.oneMemory?.exportDiagnostics) {
return { ok: false, message: translate(locale, "diagnosticExportDesktop") };
@@ -60,7 +60,7 @@ it("removes browser listeners on StrictMode cleanup and final unmount", () => {
}
});
it("ignores unrelated storage and language changes for explicit preferences", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-Hans");
mount();
vi.spyOn(navigator, "language", "get").mockReturnValue("es");
act(() => {
@@ -75,7 +75,7 @@ it("ignores unrelated storage and language changes for explicit preferences", as
}),
);
});
expect(screen.getByText("zh-CN:zh-CN")).toBeTruthy();
expect(screen.getByText("zh-Hans:zh-Hans")).toBeTruthy();
});
it("shows errors for unavailable desktop bridges without using browser fallback", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
@@ -86,7 +86,7 @@ it("shows errors for unavailable desktop bridges without using browser fallback"
expect(screen.getByRole("alert").textContent).toBe("languageLoadError");
await userEvent.click(screen.getByRole("button"));
expect(screen.getByRole("alert").textContent).toBe("languageSaveError");
expect(screen.getByText("zh-CN:system")).toBeTruthy();
expect(screen.getByText("zh-Hans:system")).toBeTruthy();
expect(read).not.toHaveBeenCalled();
expect(write).not.toHaveBeenCalled();
});
@@ -130,11 +130,11 @@ it("validates native replies and keeps the last confirmed snapshot when saving f
await waitFor(() =>
expect(screen.getByRole("alert").textContent).toBe("languageLoadError"),
);
act(() => listener({ preference: "system", locale: "zh-CN", revision: 2 }));
act(() => listener({ preference: "system", locale: "zh-Hans", revision: 2 }));
await userEvent.click(screen.getByRole("button"));
expect(set).toHaveBeenCalledWith("es");
expect(screen.getByRole("alert").textContent).toBe("languageSaveError");
expect(screen.getByText("zh-CN:system")).toBeTruthy();
act(() => listener({ preference: "system", locale: "zh-CN", revision: 2 }));
expect(screen.getByText("zh-Hans:system")).toBeTruthy();
act(() => listener({ preference: "system", locale: "zh-Hans", revision: 2 }));
expect(screen.queryByRole("alert")).toBeNull();
});
+75 -11
View File
@@ -71,7 +71,7 @@ function desktop(get: () => Promise<LanguageSnapshot>) {
beforeEach(() => {
localStorage.clear();
delete window.oneMemory;
vi.spyOn(navigator, "language", "get").mockReturnValue("zh-CN");
vi.spyOn(navigator, "language", "get").mockReturnValue("zh-Hans");
});
afterEach(() => {
cleanup();
@@ -101,7 +101,7 @@ it("keeps confirmed language and exposes storage write errors", async () => {
throw new Error("private storage");
});
await userEvent.click(screen.getByRole("button"));
expect(screen.getByText("zh-CN:system")).toBeTruthy();
expect(screen.getByText("zh-Hans:system")).toBeTruthy();
expect(screen.getByRole("alert").textContent).toBe("languageSaveError");
});
it("exposes browser read errors without crashing", () => {
@@ -114,7 +114,7 @@ it("exposes browser read errors without crashing", () => {
it("validates browser storage and synchronizes tabs and principal navigator language", () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "invalid");
mount();
expect(screen.getByText("zh-CN:system")).toBeTruthy();
expect(screen.getByText("zh-Hans:system")).toBeTruthy();
act(() => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
window.dispatchEvent(
@@ -147,7 +147,7 @@ it("subscribes before reads, rejects stale snapshots and cleans StrictMode subsc
});
await act(async () => {
for (const read of reads)
read.resolve({ preference: "zh-CN", locale: "zh-CN", revision: 1 });
read.resolve({ preference: "zh-Hans", locale: "zh-Hans", revision: 1 });
});
expect(screen.getByText("es:es")).toBeTruthy();
view.unmount();
@@ -164,7 +164,7 @@ it("never accesses browser persistence or browser locale in desktop mode and exp
);
vi.spyOn(navigator, "language", "get").mockReturnValue("es");
act(() => window.dispatchEvent(new Event("languagechange")));
expect(screen.getByText("zh-CN:system")).toBeTruthy();
expect(screen.getByText("zh-Hans:system")).toBeTruthy();
act(() => {
for (const listener of listeners)
listener({ preference: "system", locale: "es", revision: 3 });
@@ -177,7 +177,7 @@ it("never accesses browser persistence or browser locale in desktop mode and exp
it("does not surface an obsolete write failure after a newer save succeeds", async () => {
const { bridge } = desktop(async () => ({
preference: "system",
locale: "zh-CN",
locale: "zh-Hans",
revision: 0,
}));
const first = deferred<LanguageSnapshot>();
@@ -194,18 +194,18 @@ it("does not surface an obsolete write failure after a newer save succeeds", asy
});
it("rejects conflicting native locale and explicit preference pairs", async () => {
desktop(async () => ({ preference: "es", locale: "zh-CN", revision: 1 }));
desktop(async () => ({ preference: "es", locale: "zh-Hans", revision: 1 }));
mount();
await waitFor(() =>
expect(screen.getByRole("alert").textContent).toBe("languageLoadError"),
);
expect(screen.getByText("zh-CN:system")).toBeTruthy();
expect(screen.getByText("zh-Hans:system")).toBeTruthy();
});
it("does not allow a late write reply to overwrite a newer native event", async () => {
const { bridge, listeners } = desktop(async () => ({
preference: "system",
locale: "zh-CN",
locale: "zh-Hans",
revision: 0,
}));
const write = deferred<LanguageSnapshot>();
@@ -215,12 +215,12 @@ it("does not allow a late write reply to overwrite a newer native event", async
await userEvent.click(screen.getByRole("button"));
act(() => {
for (const listener of listeners)
listener({ preference: "zh-CN", locale: "zh-CN", revision: 8 });
listener({ preference: "zh-Hans", locale: "zh-Hans", revision: 8 });
});
await act(async () =>
write.resolve({ preference: "es", locale: "es", revision: 7 }),
);
expect(screen.getByText("zh-CN:zh-CN")).toBeTruthy();
expect(screen.getByText("zh-Hans:zh-Hans")).toBeTruthy();
});
it("groups counts in Chinese only where tn asks for it, never with tnPlain", () => {
@@ -259,3 +259,67 @@ it("groups counts in Chinese only where tn asks for it, never with tnPlain", ()
"1 no se pudo procesar esta vez; reintentando",
]);
});
it.each([
["zh-CN", "zh-Hans"],
["es", "es"],
["en", "en"],
])(
"keeps an existing browser user's stored %s choice as %s",
(stored, preference) => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, stored);
mount();
expect(screen.getByText(`${preference}:${preference}`)).toBeTruthy();
expect(localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe(preference);
},
);
it("tells Core the browser choice, null for the system language", async () => {
const reportToCore = vi.fn().mockRejectedValueOnce(new Error("offline"));
function Choose() {
const language = useGuiLanguage();
return (
<>
<output>{language.preference}</output>
<button onClick={() => void language.setPreference("ja")} type="button">
ja
</button>
<button
onClick={() => void language.setPreference("system")}
type="button"
>
system
</button>
</>
);
}
render(
<LanguageProvider reportToCore={reportToCore}>
<Choose />
</LanguageProvider>,
);
// Opening the browser UI tells Core nothing; only a choice does.
expect(reportToCore).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: "ja" }));
// A failed report never undoes the saved display language.
expect(screen.getByRole("status").textContent).toBe("ja");
await userEvent.click(screen.getByRole("button", { name: "system" }));
expect(reportToCore.mock.calls).toEqual([["ja"], [null]]);
});
it("leaves telling Core to the Desktop main process", async () => {
desktop(async () => ({
preference: "system",
locale: "zh-Hans",
revision: 1,
}));
const reportToCore = vi.fn();
render(
<LanguageProvider reportToCore={reportToCore}>
<Probe />
</LanguageProvider>,
);
await userEvent.click(screen.getByRole("button", { name: "Spanish" }));
await waitFor(() => expect(screen.getByText("es:es")).toBeTruthy());
expect(reportToCore).not.toHaveBeenCalled();
});
+23 -5
View File
@@ -1,8 +1,10 @@
import {
type GuiLocale,
isLanguagePreference,
isLanguageSnapshot,
type LanguagePreference,
type LanguageSnapshot,
normalizeLanguagePreference,
plainCount,
resolveGuiLocale,
translate,
@@ -20,14 +22,16 @@ import {
export const LANGUAGE_STORAGE_KEY = "memhub.ui-language";
type LanguageError = "languageLoadError" | "languageSaveError" | null;
type State = LanguageSnapshot & { error: LanguageError };
/** Tells Core the display language (null = follow the system). */
export type ReportUiLanguage = (language: GuiLocale | null) => Promise<unknown>;
const initial: State = {
preference: "system",
locale: "zh-CN",
locale: "zh-Hans",
revision: 0,
error: null,
};
function createLanguageStore() {
function createLanguageStore(reportToCore?: ReportUiLanguage) {
let state = initial;
let revision = -1;
let generation = 0;
@@ -54,7 +58,10 @@ function createLanguageStore() {
const readBrowser = () => {
try {
const stored = window.localStorage.getItem(LANGUAGE_STORAGE_KEY);
const preference = isLanguagePreference(stored) ? stored : "system";
const preference = normalizeLanguagePreference(stored) ?? "system";
// An earlier build's code (zh-CN) is kept as today's (zh-Hans).
if (stored !== null && stored !== preference && preference !== "system")
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, preference);
publish({
preference,
locale: resolveGuiLocale(preference, navigator.language),
@@ -158,6 +165,11 @@ function createLanguageStore() {
revision: state.revision + 1,
error: null,
});
// Desktop tells Core from its main process; the browser UI does it
// here. Best effort: the display language is already saved.
void reportToCore?.(
preference === "system" ? null : preference,
).catch(() => {});
}
} catch {
if (generation === current && request === writeRequest)
@@ -176,8 +188,14 @@ const LanguageContext = createContext<LanguageContextValue>({
setPreference: async () => {},
});
export function LanguageProvider({ children }: { children: ReactNode }) {
const [store] = useState(createLanguageStore);
export function LanguageProvider({
children,
reportToCore,
}: {
children: ReactNode;
reportToCore?: ReportUiLanguage;
}) {
const [store] = useState(() => createLanguageStore(reportToCore));
const state = useSyncExternalStore(
store.subscribe,
store.getSnapshot,
+2 -1
View File
@@ -2,6 +2,7 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import { setUiLanguage } from "./api";
import { LanguageProvider } from "./gui-language";
import { installNativeDisplayStyles } from "./native-display-styles";
import { installRendererDebugLogCapture } from "./renderer-debug-log";
@@ -21,7 +22,7 @@ import.meta.hot?.dispose(stopRendererDebugLogs);
createRoot(root).render(
<StrictMode>
<LanguageProvider>
<LanguageProvider reportToCore={setUiLanguage}>
<App />
</LanguageProvider>
</StrictMode>,
+1 -1
View File
@@ -113,7 +113,7 @@ function onlyRetries(blocks: readonly MemoryProcessingBlock[]): boolean {
export function memoryArrivalTitle(
arrival: MemoryArrival,
locale: GuiLocale = "zh-CN",
locale: GuiLocale = "zh-Hans",
): string {
switch (arrival.phase) {
case "checking":
+4
View File
@@ -70,6 +70,10 @@
color: inherit;
background: #f7fafc;
font: inherit;
/* One line in the fixed-height button, even for long names (fr, de, ru). */
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.onboarding-r6-titlebar .r6-language-option-menu .om-option-menu-button::after {
top: 10px;
+3
View File
@@ -61,6 +61,9 @@
background: #fff;
font-size: 13px;
line-height: 22px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.r6-settings .r6-language-option-menu .om-option-menu-button::after {
top: 16px;
+9 -9
View File
@@ -24,7 +24,7 @@ describe("presentation adapters", () => {
it.each(unknown)("memoryText returns unknown text %j untouched", (value) => {
expect(memoryText("es", value)).toBe(value);
expect(memoryText("en", value)).toBe(value);
expect(memoryText("zh-CN", value)).toBe(value);
expect(memoryText("zh-Hans", value)).toBe(value);
});
it.each(unknown)(
@@ -34,7 +34,7 @@ describe("presentation adapters", () => {
const key = value as Parameters<typeof applicationsText>[1];
expect(applicationsText("es", key)).toBe(value);
expect(applicationsText("en", key)).toBe(value);
expect(applicationsText("zh-CN", key)).toBe(value);
expect(applicationsText("zh-Hans", key)).toBe(value);
},
);
@@ -45,7 +45,7 @@ describe("presentation adapters", () => {
expect(applicationsText("es", "viewAll", { count: 1 })).toBe(
"Ver la aplicación",
);
expect(applicationsText("zh-CN", "viewAll", { count: 1 })).toBe(
expect(applicationsText("zh-Hans", "viewAll", { count: 1 })).toBe(
"查看全部 1 个应用",
);
expect(applicationsText("en", "viewAll", { count: 3 })).not.toMatch(
@@ -93,7 +93,7 @@ describe("presentation adapters", () => {
"没有匹配的原始对话",
"还没有导入的原始对话",
])("memoryText translates %j and keeps the Chinese as is", (label) => {
expect(memoryText("zh-CN", label)).toBe(label);
expect(memoryText("zh-Hans", label)).toBe(label);
const spanish = memoryText("es", label);
const english = memoryText("en", label);
expect(spanish).not.toBe(label);
@@ -103,11 +103,11 @@ describe("presentation adapters", () => {
});
it("words an action as a verb inside sentences and as a noun in history", () => {
const action = (locale: "zh-CN" | "en" | "es") =>
const action = (locale: "zh-Hans" | "en" | "es") =>
memoryText(locale, "删除(操作)");
expect(action("zh-CN")).toBe("删除");
expect(action("zh-Hans")).toBe("删除");
expect(
memoryText("zh-CN", "重试{action}", { action: action("zh-CN") }),
memoryText("zh-Hans", "重试{action}", { action: action("zh-Hans") }),
).toBe("重试删除");
expect(memoryText("es", "重试{action}", { action: action("es") })).toBe(
"Volver a eliminar",
@@ -118,7 +118,7 @@ describe("presentation adapters", () => {
error: "E",
}),
).toBe("No se pudo eliminar: E");
expect(memoryText("zh-CN", "删除(记录)")).toBe("删除");
expect(memoryText("zh-Hans", "删除(记录)")).toBe("删除");
expect(memoryText("es", "删除(记录)")).toBe("Eliminación");
expect(memoryText("es", "删除")).toBe("Eliminar");
expect(memoryText("en", "删除(记录)")).toBe("Deletion");
@@ -143,7 +143,7 @@ describe("source authorization notes", () => {
for (const message of [note as string, `${note}完成后再开启持续同步。`]) {
expect(isMessageKey(message), message).toBe(true);
if (!isMessageKey(message)) continue;
expect(translate("zh-CN", message)).toBe(message);
expect(translate("zh-Hans", message)).toBe(message);
expect(translate("es", message)).not.toMatch(/[一-鿿]/);
expect(translate("en", message)).not.toMatch(/[一-鿿]/);
}
+1 -1
View File
@@ -282,7 +282,7 @@ Support is declared per immutable GitHub Release manifest:
- Linux x64/arm64 Runtime on native GHA runners.
- macOS arm64/x64 App/Runtime: Developer ID, notarization, native acceptance.
- Windows is not supported.
- `hy-memory==2.0.10` is the current stable SDK dependency; the release
- `hy-memory==2.0.11` is the current stable SDK dependency; the release
lock and [`../MEMORY.md`](../MEMORY.md) are authoritative when the pin changes.
See [`INSTALLATION.md`](INSTALLATION.md).
+1 -1
View File
@@ -1,6 +1,6 @@
# MemHub 安装与发行
MemHub `v1.0.5`(build 36)的发行目标如下:
MemHub `v1.0.6`(build 37)的发行目标如下:
| 平台 | 发行物 | 构建与验收 |
| --- | --- | --- |
+14
View File
@@ -184,6 +184,20 @@ node apps/cli/dist/one-memory.cjs settings features reset
- `status --json` / `progress --json` 的 `pipeline.features` 列出与默认值不同的功能。
- 契约见 `docs/contracts/memory-features-v1.md`。
### 界面语言与 SDK 提示语言(`settings language`)
```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 语言)
```
- 可选 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,提取等提示使用该语言。
- 修改会像功能开关一样重建 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`。
## 8. 召回(recall)
`recall` 两条路径(raw / memory)此前已真机验证。文本输出会标明每条是「原始对话摘录」
+2 -2
View File
@@ -1,8 +1,8 @@
{
"name": "one-memory",
"version": "1.0.5",
"version": "1.0.6",
"release": {
"build_number": 36,
"build_number": 37,
"macos_min_version": "13.0",
"app_update": "manual",
"runtime_manifest_schema": 1,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@one-memory/contracts",
"version": "1.0.5",
"version": "1.0.6",
"private": true,
"type": "module",
"types": "./src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@one-memory/host-bridge",
"version": "1.0.5",
"version": "1.0.6",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@one-memory/i18n",
"version": "1.0.5",
"version": "1.0.6",
"private": true,
"type": "module",
"types": "./src/index.ts",
@@ -151,7 +151,7 @@ describe("translateCount", () => {
it("keeps the Chinese wording for every count", () => {
for (const count of [0, 1, 3])
expect(
translateCount("zh-CN", "calls.headline.found", count, {
translateCount("zh-Hans", "calls.headline.found", count, {
host: "Cursor",
count,
}),
@@ -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:
@@ -1947,6 +1948,30 @@ const baseEnglishMessages = {
startupPreparing: "Starting and preparing the memory service…",
startupUpgradeHint:
"The first launch or a memory-data upgrade can take a few minutes. MemHub opens automatically when it is done.",
"migration.dialogMessage":
"The data migration for this MemHub upgrade did not finish.",
"migration.dialogDetail":
"To protect your data, MemHub will not start. Please attach the migration log to a GitHub issue and send it to us. (Error code: {code})",
"migration.dataRestored":
"Your data was restored to its state before the upgrade.",
"migration.logPath": "Log: {path}",
"migration.openLog": "Open log",
"migration.quit": "Quit",
"migration.issueHeading": "## Upgrade migration failed",
"migration.issueUpgrade":
"- Upgrading MemHub from {fromVersion} (build {fromBuild}) to {toVersion} (build {toBuild}) failed to migrate the data.",
"migration.issueCode": "- Error code: {code}",
"migration.issueStep": "- Failed step: {step}",
"migration.issueRestored":
"- Data restored to its state before the upgrade: {restored}",
"migration.issueSystem": "- System: {system}",
"migration.issueArch": " ({arch})",
"migration.issueLog": "- Migration log (local): {path}",
"migration.issueAttach":
"Please drag the log file above here as an attachment, then submit.",
"migration.unknown": "unknown",
"migration.yes": "yes",
"migration.no": "no",
} as const;
export const englishMessages = {
@@ -24,7 +24,7 @@ it("covers every health and guide key without accidental Chinese fallback", () =
expect(translated, key).toBeTruthy();
expect(params(translated), key).toEqual(params(value));
expect(translate("es", key as MessageKey), key).toBe(translated);
expect(translate("zh-CN", key as MessageKey), key).toBe(value);
expect(translate("zh-Hans", key as MessageKey), key).toBe(value);
}
}
});
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import {
guiMessages,
type MessageKey,
SUPPORTED_LOCALES,
translate,
} from "./gui-i18n.js";
import { chineseMessages } from "./gui-i18n-messages.js";
import { japaneseMessages } from "./locales/gui-i18n-ja.js";
const placeholders = (text: string) =>
[...text.matchAll(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g)].map((m) => m[1]).sort();
/** Copy that is deliberately empty: Spanish shows no counter word. */
const INTENTIONALLY_EMPTY: Partial<Record<string, readonly string[]>> = {
es: ["apps.unit"],
};
const keys = Object.keys(chineseMessages) as MessageKey[];
describe.each(SUPPORTED_LOCALES)("%s catalog", (locale) => {
const catalog = guiMessages[locale];
it("has every key and no other", () => {
expect(Object.keys(catalog).sort()).toEqual([...keys].sort());
});
it("keeps every placeholder exactly", () => {
for (const key of keys) {
const translated = placeholders(catalog[key] ?? "");
const expected = placeholders(chineseMessages[key]);
if (key.endsWith(".one")) {
// A singular may drop {count} ("Voir l’application"), never invent one.
for (const name of translated) expect(expected, key).toContain(name);
} else {
expect(translated, `${locale} ${key}`).toEqual(expected);
}
}
});
it("has no empty string", () => {
const allowed = INTENTIONALLY_EMPTY[locale] ?? [];
for (const key of keys) {
if (allowed.includes(key)) continue;
expect(catalog[key]?.trim(), `${locale} ${key}`).toBeTruthy();
}
});
it("keeps product names untranslated", () => {
for (const key of keys) {
const english = guiMessages.en[key] ?? "";
for (const name of ["MemHub", "MCP", "GitHub"]) {
if (english.includes(name) && chineseMessages[key].includes(name))
expect(catalog[key], `${locale} ${key}`).toContain(name);
}
}
});
it("translates through the shared translator", () => {
expect(translate(locale, "settings")).toBe(catalog.settings);
});
});
describe("fallback", () => {
it("falls back to English when a locale lacks a key", () => {
const key = "languageTitle";
const saved = japaneseMessages[key];
try {
delete (japaneseMessages as Partial<typeof japaneseMessages>)[key];
expect(translate("ja", key)).toBe(guiMessages.en[key]);
} finally {
japaneseMessages[key] = saved;
}
expect(translate("ja", key)).toBe(saved);
});
});
@@ -22,6 +22,10 @@ import {
memoryChineseMessages,
memorySpanishMessages,
} from "./gui-i18n-memory.js";
import {
migrationChineseMessages,
migrationSpanishMessages,
} from "./gui-i18n-migration.js";
import {
onboardingChineseMessages,
onboardingSpanishMessages,
@@ -140,6 +144,11 @@ export const guiCatalogs: readonly Catalog[] = [
chinese: languageChineseMessages,
spanish: languageSpanishMessages,
},
{
name: "migration",
chinese: migrationChineseMessages,
spanish: migrationSpanishMessages,
},
];
/**
@@ -180,6 +189,7 @@ export const chineseMessages = merged.chinese as typeof guideChineseMessages &
typeof memoryChineseMessages &
typeof callsChineseMessages &
typeof languageChineseMessages &
typeof migrationChineseMessages &
typeof workbenchChineseMessages &
typeof currentChineseMessages;
@@ -0,0 +1,56 @@
/**
* The migration failure dialog and its GitHub issue prefill (Desktop).
* Product names, codes, versions and paths are placeholders, never copy.
*/
export const migrationChineseMessages = {
"migration.dialogMessage": "MemHub 升级时数据迁移没有完成。",
"migration.dialogDetail":
"为保护你的数据,MemHub 不会启动。请把迁移日志附在 GitHub 反馈里发给我们。(错误码:{code})",
"migration.dataRestored": "记忆数据已恢复到升级前的状态。",
"migration.logPath": "日志:{path}",
"migration.openLog": "打开日志",
"migration.quit": "退出",
"migration.issueHeading": "## 升级迁移失败",
"migration.issueUpgrade":
"- 从 MemHub {fromVersion}(build {fromBuild})升级到 {toVersion}(build {toBuild}),迁移数据失败。",
"migration.issueCode": "- 错误码:{code}",
"migration.issueStep": "- 失败步骤:{step}",
"migration.issueRestored": "- 数据已恢复到升级前状态:{restored}",
"migration.issueSystem": "- 系统:{system}",
"migration.issueArch": "({arch})",
"migration.issueLog": "- 迁移日志(本地):{path}",
"migration.issueAttach": "请把上面这个日志文件拖到这里作为附件,再提交。",
"migration.unknown": "未知",
"migration.yes": "是",
"migration.no": "否",
} as const;
export const migrationSpanishMessages: Record<
keyof typeof migrationChineseMessages,
string
> = {
"migration.dialogMessage":
"La migración de datos de esta actualización de MemHub no se completó.",
"migration.dialogDetail":
"Para proteger tus datos, MemHub no se iniciará. Adjunta el registro de migración a una incidencia de GitHub y envíanoslo. (Código de error: {code})",
"migration.dataRestored":
"Tus datos se restauraron al estado anterior a la actualización.",
"migration.logPath": "Registro: {path}",
"migration.openLog": "Abrir registro",
"migration.quit": "Salir",
"migration.issueHeading": "## Falló la migración de la actualización",
"migration.issueUpgrade":
"- Al actualizar MemHub de {fromVersion} (build {fromBuild}) a {toVersion} (build {toBuild}), falló la migración de datos.",
"migration.issueCode": "- Código de error: {code}",
"migration.issueStep": "- Paso fallido: {step}",
"migration.issueRestored":
"- Datos restaurados al estado anterior a la actualización: {restored}",
"migration.issueSystem": "- Sistema: {system}",
"migration.issueArch": " ({arch})",
"migration.issueLog": "- Registro de migración (local): {path}",
"migration.issueAttach":
"Arrastra aquí el archivo de registro indicado arriba como adjunto y luego envía la incidencia.",
"migration.unknown": "desconocido",
"migration.yes": "sí",
"migration.no": "no",
};
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff. Show more