Compare commits

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

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

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

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

Fixes #236, Fixes #235

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 12:35:14 +08:00
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
114 changed files with 19257 additions and 315 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,273 @@
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 ? "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("界面语言:未设置(SDK 自动识别)");
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();
}
}
+185
View File
@@ -0,0 +1,185 @@
/**
* `memhub settings language [<code>|system]`.
*
* The display language the App reports to the Runtime (`ui_language`), and
* the SDK prompt language it implies. Every code, zh-Hans and en included,
* makes extraction and the other SDK prompts use that language; "system"
* (no language set) passes none, so the SDK keeps detecting Chinese or
* English as before. The App sends the language it shows, so it is only
* unset on an install without the App. A change applies at once: a write in flight
* finishes in the old language, the next ones use the new one. The App sends
* its own choice again whenever it changes there.
*/
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
? "未设置(SDK 自动识别)"
: `${state.ui_language}(${NAMES[state.ui_language]})`
}`,
`SDK 提示语言:${sdkText(state.sdk_language)}`,
];
if (document.state === "ok") {
lines.push(
`可选:${UI_LANGUAGES.join("、")}、system。任一语言(包括 zh-Hans、en)都会让 SDK 使用该语言;system 不指定语言,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 =
#: none set (the App sends the language it shows, even following the system).
ui_language: UiLanguage | None = None
#: The SDK prompt language: `auto` (the SDK detects zh/en) or a code.
sdk_language: str = SDK_LANGUAGE_AUTO
class UiLanguageState(BaseModel):
"""`GET /api/v1/settings/ui-language`."""
model_config = ConfigDict(extra="forbid")
#: What the App reported; null = none set (CLI/API-only, or `system`).
ui_language: UiLanguage | None
#: What SDK clients are built with: `auto` (no language set: the SDK
#: detects zh/en, as before) or the code passed as `MemoryConfig.language`.
sdk_language: str
languages: tuple[UiLanguage, ...] = UI_LANGUAGES
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.
Every code (zh-Hans and en included) is passed as
`MemoryConfig.language`; only null passes no language, so the SDK
keeps detecting zh/en as before. A change of that SDK language rebuilds the
SDK clients like a feature change: a write in flight finishes in the
old language. Nothing stored is migrated and no collection is renamed.
"""
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 (no language set) sets nothing: the SDK detects zh/en as it always
did. Every display language, zh-Hans included, is set explicitly. Like a feature, the language never touches `vector_store`, so
the collection identity never depends on it.
"""
if language is not None:
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 the App shows (its choice, or the system language
#: it follows); None = none set. See `one_memory.ui_language` for what it
#: does to the SDK.
ui_language: UiLanguage | None = None
@field_validator("sources")
@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,77 @@
"""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 means no language was set (a CLI/API-only install, or
`settings language system`). An App that follows the system sends the
language it resolved and shows, not null.
The owner's rule for the SDK:
- Any supported code, zh-Hans and en included: pass it as
`MemoryConfig.language`, so extraction and the other prompts use that
language. Every display language is passed explicitly (#243).
- No language at all (never set, as on a CLI/API-only install): pass no
language. The SDK keeps detecting Chinese or English from the text, exactly
as before this setting existed. The App does not leave it unset: it sends
the language it shows, including when it follows the system.
The language never names a collection; a change rebuilds the SDK handles the
way a mode or feature change does (#218 use count).
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"}
#: 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.
Every supported code is passed as is; only an unset (or unknown) value
leaves the SDK to detect zh/en.
"""
return normalize_ui_language(ui_language)
+2 -1
View File
@@ -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",
+331
View File
@@ -0,0 +1,331 @@
"""The App's display language, and the SDK prompt language it implies.
The owner's rule (#243): every display language, zh-Hans and en included, is
passed as `MemoryConfig.language` (hy-memory 2.0.11); only an unset language
passes none, so the SDK keeps detecting zh/en exactly as before. A change rebuilds the
handles the way a mode or feature change does, and the language never touches
the vector collection identity.
"""
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 is passed to the SDK, zh-Hans included.
PASSED = ("en", "zh-Hans", "zh-Hant", "ja", "es", "de", "pt", "fr", "ko", "ru", "id")
@pytest.fixture(autouse=True)
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 PASSED == UI_LANGUAGES
assert len(UI_LANGUAGES) == 11
# Earlier App builds stored zh-CN; it is zh-Hans now.
assert normalize_ui_language("zh-CN") == "zh-Hans"
assert normalize_ui_language("system") is None
assert normalize_ui_language("xx") is None
assert normalize_ui_language(None) is None
def test_only_an_unset_language_leaves_the_sdk_to_detect() -> None:
assert sdk_language(None) is None
assert sdk_language("system") is None
assert sdk_language("xx") is None
# zh-Hans is passed like every other code; the legacy zh-CN maps to it.
assert sdk_language("zh-Hans") == "zh-Hans"
assert sdk_language("zh-CN") == "zh-Hans"
for code in PASSED:
assert sdk_language(code) == code
def test_no_language_builds_the_config_of_before(tmp_path) -> None:
assert _config(tmp_path, None).language is None
assert _config(tmp_path, sdk_language("zh-Hans")).language == "zh-Hans"
@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 is passed explicitly: Chinese prompts, handles rebuilt (#243).
body = (
await client.put("/api/v1/settings/ui-language", json={"ui_language": "zh-Hans"})
).json()
assert body == {
**current,
"ui_language": "zh-Hans",
"sdk_language": "zh-Hans",
"rebuilt": True,
}
assert reads.current_language() == "zh-Hans" and writes.sdk_language() == "zh-Hans"
assert retired == ["zh-Hans"]
processing = (await client.get("/api/v1/memory-processing")).json()
assert processing["pipeline"]["sdk_language"] == "zh-Hans"
# The same value again changes nothing.
body = (
await client.put("/api/v1/settings/ui-language", json={"ui_language": "zh-Hans"})
).json()
assert body["rebuilt"] is False and retired == ["zh-Hans"]
body = (await client.put("/api/v1/settings/ui-language", json={"ui_language": "ja"})).json()
assert body["sdk_language"] == "ja" and body["rebuilt"] is True
assert reads.current_language() == "ja" and writes.sdk_language() == "ja"
assert retired == ["zh-Hans", "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"
# Unset (CLI `settings language system`): auto again.
body = (await client.put("/api/v1/settings/ui-language", json={"ui_language": None})).json()
assert body["sdk_language"] == "auto" and body["rebuilt"] is True
assert reads.current_language() is None
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([
{
@@ -28,8 +28,8 @@ function fixture(
};
let changed: (() => void) | undefined;
const languageSnapshot: LanguageSnapshot = {
preference: "zh-CN",
locale: "zh-CN",
preference: "zh-Hans",
locale: "zh-Hans",
revision: 1,
};
const app = { isPackaged: packaged, quit: vi.fn() };
@@ -121,7 +121,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);
+2 -2
View File
@@ -44,7 +44,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;
@@ -82,7 +82,7 @@ export function installBackgroundTray(options: TrayOptions): () => void {
.catch(() =>
options.reportError(
translate(
options.language?.read().locale ?? "zh-CN",
options.language?.read().locale ?? "zh-Hans",
"nativeLoginError",
),
),
@@ -15,9 +15,13 @@ it("bakes fallback release identity when the packaging pipeline omits it", async
expect(config).toMatch(
/__ONE_MEMORY_BUILD_NUMBER__: JSON\.stringify\(\s*String\(releaseBuildNumber\)/,
);
expect(config).toMatch(
/process\.env\.ONE_MEMORY_COMMIT\?\.trim\(\) \|\| readSourceCommit\(\)/,
expect(config).toContain(
"const releaseCommit = resolveReleaseCommit(process.env.ONE_MEMORY_COMMIT)",
);
expect(config).toMatch(/if \(!trimmed\) return readSourceCommit\(\);/);
// A supplied commit must be the full 40-character form the runtime accepts.
expect(config).toContain("/^[0-9a-f]{40}$/i.test(trimmed)");
expect(config).toContain("return trimmed.toLowerCase();");
expect(config).toMatch(
/execFileSync\("git", \["rev-parse", "HEAD"\],[\s\S]*?encoding: "utf8"/,
);
@@ -0,0 +1,83 @@
import type { GuiLocale, 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 the language the GUI shows, system included", () => {
const shown = (
preference: LanguageSnapshot["preference"],
locale: GuiLocale,
) => coreUiLanguage({ preference, locale, revision: 0 });
expect(shown("system", "zh-Hans")).toBe("zh-Hans");
expect(shown("system", "en")).toBe("en");
expect(shown("zh-Hans", "zh-Hans")).toBe("zh-Hans");
expect(shown("en", "en")).toBe("en");
expect(shown("ja", "ja")).toBe("ja");
});
it("sends the stored choice once at launch, then every change", async () => {
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"], ["en"]]);
dispose();
language.change({ preference: "de", locale: "de", revision: 3 });
await settle();
expect(send).toHaveBeenCalledTimes(3);
});
it("an untouched install sends the system language the GUI shows", async () => {
const language = source({
preference: "system",
locale: "zh-Hans",
revision: 0,
});
const send = vi.fn(async () => {});
installCoreLanguageSync({ language, send });
await settle();
expect(send.mock.calls).toEqual([["zh-Hans"]]);
// Choosing the language already shown changes nothing Core sees.
language.change({ preference: "zh-Hans", locale: "zh-Hans", revision: 1 });
await settle();
expect(send).toHaveBeenCalledTimes(1);
});
it("reports a failed send and retries it with the next change", async () => {
const language = source({ preference: "fr", locale: "fr", revision: 0 });
const send = vi
.fn<(value: string) => 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,50 @@
import type { GuiLocale, LanguageSnapshot } from "@one-memory/i18n";
import type { NativeLanguageSource } from "./native-language";
/**
* What Core is told: the language the GUI shows. "Follow the system" sends
* the language it resolved to, so the memories match the interface a person
* sees; the App never leaves the SDK to detect zh/en (#243).
*/
export function coreUiLanguage(snapshot: LanguageSnapshot): GuiLocale {
return snapshot.locale;
}
/**
* Keep Core's `ui_language` setting on the display language the GUI shows.
*
* Core builds every SDK client with it and passes it, zh-Hans included, as
* the SDK prompt language. Sent once when installed, so a choice made before
* this existed, or the system language followed at this launch, reaches
* Core; then on every committed change. A value Core already has is not sent
* again. Best effort: a failed send is reported and retried with the next
* change or launch, and never blocks the GUI.
*/
export function installCoreLanguageSync(options: {
language: NativeLanguageSource;
send(uiLanguage: GuiLocale): Promise<void>;
reportError?(error: unknown): void;
}): () => void {
let closed = false;
let sent: GuiLocale | undefined;
let queue: Promise<void> = Promise.resolve();
const push = () => {
queue = queue.then(async () => {
if (closed) return;
const value = coreUiLanguage(options.language.read());
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.
}
@@ -526,7 +526,22 @@ describe("hourly isolated desktop launch", () => {
expect(resolvePackagedBuildNumber("-1")).toBeUndefined();
expect(resolvePackagedBuildNumber(undefined, "3907")).toBe(3907);
expect(resolvePackagedBuildNumber("3908", "3907")).toBe(3908);
expect(resolvePackagedCommit(undefined, "388fd0e9")).toBe("388fd0e9");
expect(resolvePackagedCommit("not-a-sha", "388fd0e9")).toBe("388fd0e9");
});
it("resolves only a full 40-character commit and lowercases it", () => {
// Every downstream check (packaged-runtime install, local install, release
// download, release scripts) requires 40 lowercase hex characters.
const embedded = "388fd0e9b3c41a2d5e6f7a8b9c0d1e2f3a4b5c6d";
expect(resolvePackagedCommit(undefined, embedded)).toBe(embedded);
expect(resolvePackagedCommit(embedded.toUpperCase())).toBe(embedded);
expect(
resolvePackagedCommit(` ${embedded.toUpperCase()} `, undefined),
).toBe(embedded);
expect(resolvePackagedCommit("388fd0e9", embedded)).toBe(embedded);
expect(resolvePackagedCommit("388fd0e9")).toBeUndefined();
expect(resolvePackagedCommit("not-a-sha", embedded)).toBe(embedded);
expect(resolvePackagedCommit(`${embedded}0`, embedded)).toBe(embedded);
expect(resolvePackagedCommit("g".repeat(40), embedded)).toBe(embedded);
expect(resolvePackagedCommit(undefined, "unknown")).toBeUndefined();
});
});
+7 -1
View File
@@ -143,6 +143,12 @@ export function resolvePackagedBuildNumber(
return undefined;
}
/**
* The packaged-runtime install, local install, release download and release
* scripts all require a full 40-character lowercase commit. Normalise here so
* that one format crosses this boundary: an uppercase full SHA is lowercased,
* and a short or malformed value is skipped so it cannot shadow the next source.
*/
export function resolvePackagedCommit(
environmentValue: unknown,
embeddedValue?: unknown,
@@ -150,7 +156,7 @@ export function resolvePackagedCommit(
for (const value of [environmentValue, embeddedValue]) {
if (typeof value !== "string") continue;
const trimmed = value.trim();
if (/^[0-9a-f]{7,40}$/i.test(trimmed)) return trimmed;
if (/^[0-9a-f]{40}$/i.test(trimmed)) return trimmed.toLowerCase();
}
return undefined;
}
@@ -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.
}
+51 -8
View File
@@ -59,6 +59,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,
@@ -151,6 +152,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 +167,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),
});
@@ -296,9 +298,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.
}
@@ -1404,16 +1406,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 +1520,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",
@@ -1657,6 +1696,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();
@@ -1708,6 +1749,8 @@ if (desktopVersion !== null) {
});
},
dispose: () => {
disposeCoreLanguageSync?.();
disposeCoreLanguageSync = null;
disposeLanguagePreferences?.();
disposeLanguagePreferences = null;
disposeApplicationMenu?.();
@@ -1738,7 +1781,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 +1809,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)),
@@ -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>
+14 -2
View File
@@ -11,8 +11,20 @@ const { resolveBuildNumber, resolveBuildVersion } =
};
const releaseVersion = resolveBuildVersion(process.env);
const releaseBuildNumber = resolveBuildNumber(process.env);
const releaseCommit =
process.env.ONE_MEMORY_COMMIT?.trim() || readSourceCommit();
const releaseCommit = resolveReleaseCommit(process.env.ONE_MEMORY_COMMIT);
// The runtime accepts only a full 40-character commit, so an explicitly
// supplied malformed value fails the build instead of being baked and skipped.
function resolveReleaseCommit(explicit: string | undefined): string {
const trimmed = explicit?.trim();
if (!trimmed) return readSourceCommit();
if (!/^[0-9a-f]{40}$/i.test(trimmed)) {
throw new Error(
`ONE_MEMORY_COMMIT must be a full 40-character hex commit, got ${JSON.stringify(trimmed)}`,
);
}
return trimmed.toLowerCase();
}
function readSourceCommit(): string {
try {
+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 每次启动也会同步一次界面实际显示的语言(App 选“跟随系统”时,发送它按系统语言解析出的语言,例如 `zh-Hans`)。全局设置,存于 `config.json` 的 `ui_language`;从未设置时不写入。
- 所有语言(包括 `zh-Hans` 和 `en`)都作为 `MemoryConfig.language` 传给 SDK,提取等提示使用该语言;只有未设置(`system`,或只用 CLI/API、从未打开 App 的安装)时不向 SDK 传语言,SDK 照旧自动识别中文或英文。
- 修改会像功能开关一样重建 SDK 客户端;不迁移数据,不改集合名(需要 hy-memory 2.0.11 的 `MemoryConfig.language`)。
- `status --json` 的 `pipeline.ui_language` / `pipeline.sdk_language`(`auto` 或语言代码;`zh-Hans` 用户显示 `zh-Hans`)。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,
}),
@@ -1947,6 +1947,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",
};
@@ -22,7 +22,7 @@ it("provides Spanish copy for every onboarding message with identical interpolat
.map((match) => match[1])
.sort(),
);
expect(translate("zh-CN", key)).toBe(chinese);
expect(translate("zh-Hans", key)).toBe(chinese);
expect(translate("es", key)).toBe(spanish);
}
});
@@ -31,7 +31,7 @@ it("keeps product names, paths and error codes intact during interpolation", ()
"Importar el historial de Cursor",
);
expect(
translate("zh-CN", "onboardingImportHistory", { name: "Cursor" }),
translate("zh-Hans", "onboardingImportHistory", { name: "Cursor" }),
).toBe("导入 Cursor 历史");
expect(
translate("es", "onboardingModelOperationFailed", {
@@ -18,6 +18,6 @@ it("covers every recovery key in Spanish with identical interpolation parameters
params(recoveryChineseMessages[key]),
);
expect(translate("es", key), key).toBe(recoverySpanishMessages[key]);
expect(translate("zh-CN", key), key).toBe(recoveryChineseMessages[key]);
expect(translate("zh-Hans", key), key).toBe(recoveryChineseMessages[key]);
}
});
+85 -20
View File
@@ -6,8 +6,11 @@ import {
isLanguageSnapshot,
type LanguagePreference,
type LanguageSnapshot,
LOCALE_NAMES,
type MessageKey,
normalizeLanguagePreference,
resolveGuiLocale,
SUPPORTED_LOCALES,
translate,
} from "./gui-i18n.js";
@@ -27,10 +30,20 @@ const messageKeys = [
describe("GUI language contracts", () => {
it("pins the language preference and snapshot types", () => {
expectTypeOf<LanguagePreference>().toEqualTypeOf<
"system" | "zh-CN" | "en" | "es"
expectTypeOf<GuiLocale>().toEqualTypeOf<
| "en"
| "zh-Hans"
| "zh-Hant"
| "ja"
| "es"
| "de"
| "pt"
| "fr"
| "ko"
| "ru"
| "id"
>();
expectTypeOf<GuiLocale>().toEqualTypeOf<"zh-CN" | "en" | "es">();
expectTypeOf<LanguagePreference>().toEqualTypeOf<"system" | GuiLocale>();
expectTypeOf<LanguageSnapshot>().toEqualTypeOf<{
preference: LanguagePreference;
locale: GuiLocale;
@@ -38,11 +51,41 @@ describe("GUI language contracts", () => {
}>();
});
it.each(["system", "zh-CN", "en", "es"])("accepts preference %s", (value) => {
expect(isLanguagePreference(value)).toBe(true);
it.each(["system", ...SUPPORTED_LOCALES])(
"accepts preference %s",
(value) => {
expect(isLanguagePreference(value)).toBe(true);
},
);
it("lists eleven languages, each by its own name", () => {
expect(SUPPORTED_LOCALES).toHaveLength(11);
expect(SUPPORTED_LOCALES.map((locale) => LOCALE_NAMES[locale])).toEqual([
"English",
"简体中文",
"繁體中文",
"日本語",
"Español",
"Deutsch",
"Português",
"Français",
"한국어",
"Русский",
"Bahasa Indonesia",
]);
});
it.each([undefined, null, "", "EN", "ES", "es-MX", {}, [], 1, true])(
it("maps the legacy zh-CN preference so existing users keep their choice", () => {
expect(isLanguagePreference("zh-CN")).toBe(false);
expect(normalizeLanguagePreference("zh-CN")).toBe("zh-Hans");
expect(normalizeLanguagePreference("es")).toBe("es");
expect(normalizeLanguagePreference("en")).toBe("en");
expect(normalizeLanguagePreference("system")).toBe("system");
for (const value of [undefined, null, "", "zh", "EN", {}, 1])
expect(normalizeLanguagePreference(value)).toBeNull();
});
it.each([undefined, null, "", "EN", "ES", "es-MX", "zh-CN", {}, [], 1, true])(
"rejects invalid preferences: %j",
(value) => expect(isLanguagePreference(value)).toBe(false),
);
@@ -60,8 +103,8 @@ describe("GUI language contracts", () => {
).toBe(true);
expect(
isLanguageSnapshot({
preference: "zh-CN",
locale: "zh-CN",
preference: "zh-Hans",
locale: "zh-Hans",
revision: Number.MAX_SAFE_INTEGER,
}),
).toBe(true);
@@ -71,8 +114,9 @@ describe("GUI language contracts", () => {
[],
{},
{ preference: "en", locale: "es", revision: 0 },
{ preference: "es", locale: "zh-CN", revision: 0 },
{ preference: "zh-CN", locale: "es", revision: 0 },
{ preference: "es", locale: "zh-Hans", revision: 0 },
{ preference: "zh-Hans", locale: "es", revision: 0 },
{ preference: "zh-CN", locale: "zh-CN", revision: 0 },
{ preference: "system", locale: "system", revision: 0 },
{ preference: "system", locale: "es" },
...[-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, "0"].map(
@@ -102,16 +146,36 @@ describe("GUI locale resolution", () => {
(language) => expect(resolveGuiLocale("system", language)).toBe("en"),
);
it.each(["zh", "zh-CN", "zh-SG", "zh-Hans"])(
it.each(["zh", "zh-CN", "zh-SG", "zh-Hans", "zh-Hans-HK"])(
"resolves Simplified Chinese family %s",
(language) => expect(resolveGuiLocale("system", language)).toBe("zh-CN"),
(language) => expect(resolveGuiLocale("system", language)).toBe("zh-Hans"),
);
it.each(["zh-TW", "zh-HK", "zh-MO", "zh-Hant", "zh-Hant-TW"])(
"resolves Traditional Chinese family %s",
(language) => expect(resolveGuiLocale("system", language)).toBe("zh-Hant"),
);
it.each([
["ja-JP", "ja"],
["de-AT", "de"],
["pt-BR", "pt"],
["pt-PT", "pt"],
["fr-ES", "fr"],
["fr-CA", "fr"],
["ko-KR", "ko"],
["ru-RU", "ru"],
["id-ID", "id"],
["in-ID", "id"],
])("resolves %s to %s", (language, locale) =>
expect(resolveGuiLocale("system", language)).toBe(locale),
);
it.each([
undefined,
"",
"zh-TW",
"fr-ES",
"it-IT",
"nl",
"en-US-u-rg-eszzzz",
"es_MX",
"es--ES",
@@ -125,7 +189,8 @@ describe("GUI locale resolution", () => {
it("prioritizes explicit preferences over the system language", () => {
expect(resolveGuiLocale("es", "zh-CN")).toBe("es");
expect(resolveGuiLocale("zh-CN", "es-MX")).toBe("zh-CN");
expect(resolveGuiLocale("zh-Hans", "es-MX")).toBe("zh-Hans");
expect(resolveGuiLocale("ja", "zh-TW")).toBe("ja");
expect(resolveGuiLocale("en", "es-MX")).toBe("en");
expect(resolveGuiLocale("es", "invalid_locale")).toBe("es");
});
@@ -135,19 +200,19 @@ describe("GUI messages", () => {
it.each(messageKeys)(
"provides Chinese, English and Spanish text for %s",
(key) => {
expect(translate("zh-CN", key).length).toBeGreaterThan(0);
expect(translate("zh-Hans", key).length).toBeGreaterThan(0);
expect(translate("en", key).length).toBeGreaterThan(0);
expect(translate("es", key).length).toBeGreaterThan(0);
expect(translate("en", key)).not.toBe(translate("zh-CN", key));
expect(translate("es", key)).not.toBe(translate("zh-CN", key));
expect(translate("en", key)).not.toBe(translate("zh-Hans", key));
expect(translate("es", key)).not.toBe(translate("zh-Hans", key));
},
);
it("provides stable settings and language names", () => {
expect(translate("zh-CN", "settings")).toBe("设置");
expect(translate("zh-Hans", "settings")).toBe("设置");
expect(translate("en", "settings")).toBe("Settings");
expect(translate("es", "settings")).toBe("Configuración");
expect(translate("zh-CN", "nativeErrorTitle")).toBe("MemHub 无法启动");
expect(translate("zh-Hans", "nativeErrorTitle")).toBe("MemHub 无法启动");
expect(translate("es", "nativeErrorTitle")).toBe(
"MemHub no pudo iniciarse",
);
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff. Show more