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
14 changed files with 154 additions and 87 deletions

No files matched your search

@@ -30,8 +30,7 @@ vi.mock("@one-memory/runtime-control", async (importOriginal) => ({
})); }));
function languageState(uiLanguage: string | null, extra = {}) { function languageState(uiLanguage: string | null, extra = {}) {
const sdk = const sdk = uiLanguage === null ? "auto" : uiLanguage;
uiLanguage === null || uiLanguage === "zh-Hans" ? "auto" : uiLanguage;
return { return {
ui_language: uiLanguage, ui_language: uiLanguage,
sdk_language: sdk, sdk_language: sdk,
@@ -95,7 +94,7 @@ describe("memhub settings language", () => {
}); });
expect(transport.put).not.toHaveBeenCalled(); expect(transport.put).not.toHaveBeenCalled();
const text = (await capturedRun(["settings", "language"])).stdout; const text = (await capturedRun(["settings", "language"])).stdout;
expect(text).toContain("界面语言:跟随系统(未选择)"); expect(text).toContain("界面语言:未设置(SDK 自动识别)");
expect(text).toContain("SDK 提示语言:auto"); expect(text).toContain("SDK 提示语言:auto");
}); });
+7 -6
View File
@@ -2,10 +2,11 @@
* `memhub settings language [<code>|system]`. * `memhub settings language [<code>|system]`.
* *
* The display language the App reports to the Runtime (`ui_language`), and * The display language the App reports to the Runtime (`ui_language`), and
* the SDK prompt language it implies. zh-Hans and "system" (no explicit * the SDK prompt language it implies. Every code, zh-Hans and en included,
* choice) pass no language, so the SDK keeps detecting Chinese or English as * makes extraction and the other SDK prompts use that language; "system"
* before; every other code, en included, makes extraction and the other SDK * (no language set) passes none, so the SDK keeps detecting Chinese or
* prompts use that language. A change applies at once: a write in flight * 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 * finishes in the old language, the next ones use the new one. The App sends
* its own choice again whenever it changes there. * its own choice again whenever it changes there.
*/ */
@@ -162,14 +163,14 @@ export function renderLanguageText(document: LanguageDocument): string {
const lines = [ const lines = [
`界面语言:${ `界面语言:${
state.ui_language === null state.ui_language === null
? "跟随系统(未选择)" ? "未设置(SDK 自动识别)"
: `${state.ui_language}(${NAMES[state.ui_language]})` : `${state.ui_language}(${NAMES[state.ui_language]})`
}`, }`,
`SDK 提示语言:${sdkText(state.sdk_language)}`, `SDK 提示语言:${sdkText(state.sdk_language)}`,
]; ];
if (document.state === "ok") { if (document.state === "ok") {
lines.push( lines.push(
`可选:${UI_LANGUAGES.join("、")}、system。简体中文和 system 不指定语言,其余语言(包括 en)会让 SDK 使用该语言。`, `可选:${UI_LANGUAGES.join("、")}、system。任一语言(包括 zh-Hans、en)都会让 SDK 使用该语言;system 不指定语言,SDK 自动识别中文或英文。`,
); );
} }
if (document.state === "unchanged") lines.push("没有需要改动的设置。"); if (document.state === "unchanged") lines.push("没有需要改动的设置。");
+6 -6
View File
@@ -766,7 +766,7 @@ class PipelineStatus(BaseModel):
#: default; empty while every feature is at its default. #: default; empty while every feature is at its default.
features: dict[MemoryFeatureName, bool] = Field(default_factory=dict) features: dict[MemoryFeatureName, bool] = Field(default_factory=dict)
#: The display language the App last reported (`ui_language`); null = #: The display language the App last reported (`ui_language`); null =
#: never chosen or "follow the system". #: none set (the App sends the language it shows, even following the system).
ui_language: UiLanguage | None = None ui_language: UiLanguage | None = None
#: The SDK prompt language: `auto` (the SDK detects zh/en) or a code. #: The SDK prompt language: `auto` (the SDK detects zh/en) or a code.
sdk_language: str = SDK_LANGUAGE_AUTO sdk_language: str = SDK_LANGUAGE_AUTO
@@ -777,9 +777,9 @@ class UiLanguageState(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
#: What the App reported; null = never chosen or "follow the system". #: What the App reported; null = none set (CLI/API-only, or `system`).
ui_language: UiLanguage | None ui_language: UiLanguage | None
#: What SDK clients are built with: `auto` (zh-Hans or no choice: the SDK #: What SDK clients are built with: `auto` (no language set: the SDK
#: detects zh/en, as before) or the code passed as `MemoryConfig.language`. #: detects zh/en, as before) or the code passed as `MemoryConfig.language`.
sdk_language: str sdk_language: str
languages: tuple[UiLanguage, ...] = UI_LANGUAGES languages: tuple[UiLanguage, ...] = UI_LANGUAGES
@@ -4616,9 +4616,9 @@ def create_app(
async def put_ui_language(request: UiLanguageUpdate) -> UiLanguageChange: async def put_ui_language(request: UiLanguageUpdate) -> UiLanguageChange:
"""Record the App's display language; one global setting, not per user. """Record the App's display language; one global setting, not per user.
zh-Hans and null pass no language to the SDK, which keeps detecting Every code (zh-Hans and en included) is passed as
zh/en as before; every other code (en included) is passed as `MemoryConfig.language`; only null passes no language, so the SDK
`MemoryConfig.language`. A change of that SDK language rebuilds the 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 SDK clients like a feature change: a write in flight finishes in the
old language. Nothing stored is migrated and no collection is renamed. old language. Nothing stored is migrated and no collection is renamed.
""" """
@@ -367,8 +367,8 @@ def language_kwargs(language: str | None) -> LanguageKwargs:
def apply_sdk_language(config: MemoryConfig, language: str | None) -> MemoryConfig: def apply_sdk_language(config: MemoryConfig, language: str | None) -> MemoryConfig:
"""Set the SDK prompt language (`MemoryConfig.language`), when there is one. """Set the SDK prompt language (`MemoryConfig.language`), when there is one.
None (zh-Hans, or no choice) sets nothing: the SDK detects zh/en as it None (no language set) sets nothing: the SDK detects zh/en as it always
always did. Like a feature, the language never touches `vector_store`, so 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. the collection identity never depends on it.
""" """
if language is not None: if language is not None:
@@ -74,9 +74,9 @@ class ConfigDocument(BaseModel):
#: SDK retrieval and extraction features a person set; an unset one keeps #: SDK retrieval and extraction features a person set; an unset one keeps
#: the SDK default. See `one_memory.memory_features`. #: the SDK default. See `one_memory.memory_features`.
memory_features: MemoryFeatures = Field(default_factory=MemoryFeatures) memory_features: MemoryFeatures = Field(default_factory=MemoryFeatures)
#: The display language a person chose in the App; None = never chosen or #: The display language the App shows (its choice, or the system language
#: "follow the system". See `one_memory.ui_language` for what it does to #: it follows); None = none set. See `one_memory.ui_language` for what it
#: the SDK. #: does to the SDK.
ui_language: UiLanguage | None = None ui_language: UiLanguage | None = None
@field_validator("sources") @field_validator("sources")
+16 -12
View File
@@ -3,14 +3,19 @@
The App owns the choice (its language selector) and tells Core whenever it 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 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`; `config.json` (`ui_language`), beside `memory_mode` and `memory_features`;
absent (never chosen, or "follow the system") means no explicit choice. absent means no language was set (a CLI/API-only install, or
`settings language system`). An App that follows the system sends the
language it resolved and shows, not null.
The owner's rule for the SDK: The owner's rule for the SDK:
- zh-Hans, or no explicit choice: pass no language. The SDK keeps detecting - Any supported code, zh-Hans and en included: pass it as
Chinese or English from the text, exactly as before this setting existed. `MemoryConfig.language`, so extraction and the other prompts use that
- Any other code, including en: pass it as `MemoryConfig.language`, so language. Every display language is passed explicitly (#243).
extraction and the other prompts use that language. - 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 The language never names a collection; a change rebuilds the SDK handles the
way a mode or feature change does (#218 use count). way a mode or feature change does (#218 use count).
@@ -51,8 +56,6 @@ UI_LANGUAGES: tuple[UiLanguage, ...] = (
) )
#: Codes earlier App builds stored, and the code each one is now. #: Codes earlier App builds stored, and the code each one is now.
LEGACY_UI_LANGUAGES: dict[str, UiLanguage] = {"zh-CN": "zh-Hans"} LEGACY_UI_LANGUAGES: dict[str, UiLanguage] = {"zh-CN": "zh-Hans"}
#: Display languages that leave the SDK's own zh/en detection in charge.
AUTO_DETECTED: frozenset[str] = frozenset({"zh-Hans"})
#: What status shows when no language is passed to the SDK. #: What status shows when no language is passed to the SDK.
SDK_LANGUAGE_AUTO = "auto" SDK_LANGUAGE_AUTO = "auto"
@@ -66,8 +69,9 @@ def normalize_ui_language(value: object) -> UiLanguage | None:
def sdk_language(ui_language: str | None) -> str | None: def sdk_language(ui_language: str | None) -> str | None:
"""The `MemoryConfig.language` for a display language; None = SDK auto-detect.""" """The `MemoryConfig.language` for a display language; None = SDK auto-detect.
language = normalize_ui_language(ui_language)
if language is None or language in AUTO_DETECTED: Every supported code is passed as is; only an unset (or unknown) value
return None leaves the SDK to detect zh/en.
return language """
return normalize_ui_language(ui_language)
+32 -17
View File
@@ -1,8 +1,8 @@
"""The App's display language, and the SDK prompt language it implies. """The App's display language, and the SDK prompt language it implies.
The owner's rule: zh-Hans, or no explicit choice, passes no language, so the The owner's rule (#243): every display language, zh-Hans and en included, is
SDK keeps detecting zh/en exactly as before; every other code (en included) passed as `MemoryConfig.language` (hy-memory 2.0.11); only an unset language
is passed as `MemoryConfig.language` (hy-memory 2.0.11). A change rebuilds the 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 handles the way a mode or feature change does, and the language never touches
the vector collection identity. the vector collection identity.
""" """
@@ -28,8 +28,8 @@ from one_memory.secrets import InMemorySecretStore
from one_memory.ui_language import UI_LANGUAGES, normalize_ui_language, sdk_language from one_memory.ui_language import UI_LANGUAGES, normalize_ui_language, sdk_language
TOKEN = "ui-language-test" TOKEN = "ui-language-test"
#: Every code except zh-Hans is passed to the SDK. #: Every code is passed to the SDK, zh-Hans included.
PASSED = ("en", "zh-Hant", "ja", "es", "de", "pt", "fr", "ko", "ru", "id") PASSED = ("en", "zh-Hans", "zh-Hant", "ja", "es", "de", "pt", "fr", "ko", "ru", "id")
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -51,7 +51,7 @@ def _config(tmp_path: Path, language: str | None):
def test_eleven_codes_and_the_legacy_chinese_code() -> None: def test_eleven_codes_and_the_legacy_chinese_code() -> None:
assert ("en", "zh-Hans", *PASSED[1:]) == UI_LANGUAGES assert PASSED == UI_LANGUAGES
assert len(UI_LANGUAGES) == 11 assert len(UI_LANGUAGES) == 11
# Earlier App builds stored zh-CN; it is zh-Hans now. # Earlier App builds stored zh-CN; it is zh-Hans now.
assert normalize_ui_language("zh-CN") == "zh-Hans" assert normalize_ui_language("zh-CN") == "zh-Hans"
@@ -60,19 +60,20 @@ def test_eleven_codes_and_the_legacy_chinese_code() -> None:
assert normalize_ui_language(None) is None assert normalize_ui_language(None) is None
def test_only_zh_hans_and_no_choice_leave_the_sdk_to_detect() -> None: def test_only_an_unset_language_leaves_the_sdk_to_detect() -> None:
assert sdk_language(None) is None assert sdk_language(None) is None
assert sdk_language("zh-Hans") is None
assert sdk_language("zh-CN") is None
assert sdk_language("system") is None assert sdk_language("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: for code in PASSED:
assert sdk_language(code) == code assert sdk_language(code) == code
def test_zh_hans_and_no_choice_build_the_config_of_before(tmp_path) -> None: def test_no_language_builds_the_config_of_before(tmp_path) -> None:
before = repr(dataclasses.asdict(_config(tmp_path, None)))
assert _config(tmp_path, None).language is None assert _config(tmp_path, None).language is None
assert repr(dataclasses.asdict(_config(tmp_path, sdk_language("zh-Hans")))) == before assert _config(tmp_path, sdk_language("zh-Hans")).language == "zh-Hans"
@pytest.mark.parametrize("code", PASSED) @pytest.mark.parametrize("code", PASSED)
@@ -282,17 +283,31 @@ def test_ui_language_api_get_put_and_status(tmp_path, monkeypatch) -> None:
assert current["languages"] == list(UI_LANGUAGES) assert current["languages"] == list(UI_LANGUAGES)
assert reads.current_language() is None and writes.sdk_language() is None assert reads.current_language() is None and writes.sdk_language() is None
# zh-Hans: recorded, but the SDK still detects zh/en; nothing rebuilt. # zh-Hans is passed explicitly: Chinese prompts, handles rebuilt (#243).
body = ( body = (
await client.put("/api/v1/settings/ui-language", json={"ui_language": "zh-Hans"}) await client.put("/api/v1/settings/ui-language", json={"ui_language": "zh-Hans"})
).json() ).json()
assert body == {**current, "ui_language": "zh-Hans", "rebuilt": False} assert body == {
assert retired == [] and reads.current_language() is None **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() 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 body["sdk_language"] == "ja" and body["rebuilt"] is True
assert reads.current_language() == "ja" and writes.sdk_language() == "ja" assert reads.current_language() == "ja" and writes.sdk_language() == "ja"
assert retired == ["ja"] assert retired == ["zh-Hans", "ja"]
# An explicit English choice forces English prompts. # An explicit English choice forces English prompts.
body = (await client.put("/api/v1/settings/ui-language", json={"ui_language": "en"})).json() body = (await client.put("/api/v1/settings/ui-language", json={"ui_language": "en"})).json()
@@ -302,7 +317,7 @@ def test_ui_language_api_get_put_and_status(tmp_path, monkeypatch) -> None:
assert processing["pipeline"]["ui_language"] == "en" assert processing["pipeline"]["ui_language"] == "en"
assert processing["pipeline"]["sdk_language"] == "en" assert processing["pipeline"]["sdk_language"] == "en"
# Back to "follow the system": auto again. # Unset (CLI `settings language system`): auto again.
body = (await client.put("/api/v1/settings/ui-language", json={"ui_language": None})).json() 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 body["sdk_language"] == "auto" and body["rebuilt"] is True
assert reads.current_language() is None assert reads.current_language() is None
@@ -15,9 +15,13 @@ it("bakes fallback release identity when the packaging pipeline omits it", async
expect(config).toMatch( expect(config).toMatch(
/__ONE_MEMORY_BUILD_NUMBER__: JSON\.stringify\(\s*String\(releaseBuildNumber\)/, /__ONE_MEMORY_BUILD_NUMBER__: JSON\.stringify\(\s*String\(releaseBuildNumber\)/,
); );
expect(config).toMatch( expect(config).toContain(
/process\.env\.ONE_MEMORY_COMMIT\?\.trim\(\) \|\| readSourceCommit\(\)/, "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( expect(config).toMatch(
/execFileSync\("git", \["rev-parse", "HEAD"\],[\s\S]*?encoding: "utf8"/, /execFileSync\("git", \["rev-parse", "HEAD"\],[\s\S]*?encoding: "utf8"/,
); );
@@ -1,4 +1,4 @@
import type { LanguageSnapshot } from "@one-memory/i18n"; import type { GuiLocale, LanguageSnapshot } from "@one-memory/i18n";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { coreUiLanguage, installCoreLanguageSync } from "./core-language-sync"; import { coreUiLanguage, installCoreLanguageSync } from "./core-language-sync";
@@ -21,11 +21,16 @@ function source(initial: LanguageSnapshot) {
const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
describe("Core language sync", () => { describe("Core language sync", () => {
it("tells Core null for system and the code for an explicit choice", () => { it("tells Core the language the GUI shows, system included", () => {
expect(coreUiLanguage("system")).toBeNull(); const shown = (
expect(coreUiLanguage("zh-Hans")).toBe("zh-Hans"); preference: LanguageSnapshot["preference"],
expect(coreUiLanguage("en")).toBe("en"); locale: GuiLocale,
expect(coreUiLanguage("ja")).toBe("ja"); ) => 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 () => { it("sends the stored choice once at launch, then every change", async () => {
@@ -38,29 +43,33 @@ describe("Core language sync", () => {
await settle(); await settle();
language.change({ preference: "system", locale: "en", revision: 2 }); language.change({ preference: "system", locale: "en", revision: 2 });
await settle(); await settle();
expect(send.mock.calls).toEqual([["es"], ["ja"], [null]]); expect(send.mock.calls).toEqual([["es"], ["ja"], ["en"]]);
dispose(); dispose();
language.change({ preference: "de", locale: "de", revision: 3 }); language.change({ preference: "de", locale: "de", revision: 3 });
await settle(); await settle();
expect(send).toHaveBeenCalledTimes(3); expect(send).toHaveBeenCalledTimes(3);
}); });
it("an untouched install sends null, which leaves the SDK on auto", async () => { it("an untouched install sends the system language the GUI shows", async () => {
const language = source({ const language = source({
preference: "system", preference: "system",
locale: "ja", locale: "zh-Hans",
revision: 0, revision: 0,
}); });
const send = vi.fn(async () => {}); const send = vi.fn(async () => {});
installCoreLanguageSync({ language, send }); installCoreLanguageSync({ language, send });
await settle(); await settle();
expect(send.mock.calls).toEqual([[null]]); expect(send.mock.calls).toEqual([["zh-Hans"]]);
// Choosing the language already shown changes nothing Core sees.
language.change({ preference: "zh-Hans", locale: "zh-Hans", revision: 1 });
await settle();
expect(send).toHaveBeenCalledTimes(1);
}); });
it("reports a failed send and retries it with the next change", async () => { it("reports a failed send and retries it with the next change", async () => {
const language = source({ preference: "fr", locale: "fr", revision: 0 }); const language = source({ preference: "fr", locale: "fr", revision: 0 });
const send = vi const send = vi
.fn<(value: string | null) => Promise<void>>() .fn<(value: string) => Promise<void>>()
.mockRejectedValueOnce(new Error("core_unavailable")) .mockRejectedValueOnce(new Error("core_unavailable"))
.mockResolvedValue(undefined); .mockResolvedValue(undefined);
const reportError = vi.fn(); const reportError = vi.fn();
+18 -16
View File
@@ -1,35 +1,37 @@
import type { GuiLocale, LanguagePreference } from "@one-memory/i18n"; import type { GuiLocale, LanguageSnapshot } from "@one-memory/i18n";
import type { NativeLanguageSource } from "./native-language"; import type { NativeLanguageSource } from "./native-language";
/** What Core is told: a code, or null for "follow the system". */ /**
export function coreUiLanguage( * What Core is told: the language the GUI shows. "Follow the system" sends
preference: LanguagePreference, * the language it resolved to, so the memories match the interface a person
): GuiLocale | null { * sees; the App never leaves the SDK to detect zh/en (#243).
return preference === "system" ? null : preference; */
export function coreUiLanguage(snapshot: LanguageSnapshot): GuiLocale {
return snapshot.locale;
} }
/** /**
* Keep Core's `ui_language` setting on the display language a person chose. * Keep Core's `ui_language` setting on the display language the GUI shows.
* *
* Core builds every SDK client with it: zh-Hans and "follow the system" pass * Core builds every SDK client with it and passes it, zh-Hans included, as
* no language (the SDK keeps detecting zh/en), any other choice sets the SDK * the SDK prompt language. Sent once when installed, so a choice made before
* prompt language. Sent once when installed, so a choice made before this * this existed, or the system language followed at this launch, reaches
* existed reaches Core, then on every committed change; a value Core already * Core; then on every committed change. A value Core already has is not sent
* has is not sent again. Best effort: a failed send is reported and retried * again. Best effort: a failed send is reported and retried with the next
* with the next change or launch, and never blocks the GUI. * change or launch, and never blocks the GUI.
*/ */
export function installCoreLanguageSync(options: { export function installCoreLanguageSync(options: {
language: NativeLanguageSource; language: NativeLanguageSource;
send(uiLanguage: GuiLocale | null): Promise<void>; send(uiLanguage: GuiLocale): Promise<void>;
reportError?(error: unknown): void; reportError?(error: unknown): void;
}): () => void { }): () => void {
let closed = false; let closed = false;
let sent: GuiLocale | null | undefined; let sent: GuiLocale | undefined;
let queue: Promise<void> = Promise.resolve(); let queue: Promise<void> = Promise.resolve();
const push = () => { const push = () => {
queue = queue.then(async () => { queue = queue.then(async () => {
if (closed) return; if (closed) return;
const value = coreUiLanguage(options.language.read().preference); const value = coreUiLanguage(options.language.read());
if (value === sent) return; if (value === sent) return;
try { try {
await options.send(value); await options.send(value);
@@ -526,7 +526,22 @@ describe("hourly isolated desktop launch", () => {
expect(resolvePackagedBuildNumber("-1")).toBeUndefined(); expect(resolvePackagedBuildNumber("-1")).toBeUndefined();
expect(resolvePackagedBuildNumber(undefined, "3907")).toBe(3907); expect(resolvePackagedBuildNumber(undefined, "3907")).toBe(3907);
expect(resolvePackagedBuildNumber("3908", "3907")).toBe(3908); 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; 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( export function resolvePackagedCommit(
environmentValue: unknown, environmentValue: unknown,
embeddedValue?: unknown, embeddedValue?: unknown,
@@ -150,7 +156,7 @@ export function resolvePackagedCommit(
for (const value of [environmentValue, embeddedValue]) { for (const value of [environmentValue, embeddedValue]) {
if (typeof value !== "string") continue; if (typeof value !== "string") continue;
const trimmed = value.trim(); 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; return undefined;
} }
+14 -2
View File
@@ -11,8 +11,20 @@ const { resolveBuildNumber, resolveBuildVersion } =
}; };
const releaseVersion = resolveBuildVersion(process.env); const releaseVersion = resolveBuildVersion(process.env);
const releaseBuildNumber = resolveBuildNumber(process.env); const releaseBuildNumber = resolveBuildNumber(process.env);
const releaseCommit = const releaseCommit = resolveReleaseCommit(process.env.ONE_MEMORY_COMMIT);
process.env.ONE_MEMORY_COMMIT?.trim() || readSourceCommit();
// 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 { function readSourceCommit(): string {
try { try {
+4 -4
View File
@@ -189,14 +189,14 @@ node apps/cli/dist/one-memory.cjs settings features reset
```bash ```bash
node apps/cli/dist/one-memory.cjs settings language # 界面语言和 SDK 提示语言 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 ja # 设为日语,立即生效
node apps/cli/dist/one-memory.cjs settings language system # 跟随系统(不指定 SDK 语言) node apps/cli/dist/one-memory.cjs settings language system # 清除设置(不指定 SDK 语言)
``` ```
- 可选 11 种:`en`、`zh-Hans`、`zh-Hant`、`ja`、`es`、`de`、`pt`、`fr`、`ko`、`ru`、`id`,以及 `system`;旧代码 `zh-CN` 按 `zh-Hans` 处理。 - 可选 11 种:`en`、`zh-Hans`、`zh-Hant`、`ja`、`es`、`de`、`pt`、`fr`、`ko`、`ru`、`id`,以及 `system`;旧代码 `zh-CN` 按 `zh-Hans` 处理。
- App 切换语言时会自动设置(Desktop 启动时也会同步一次已保存的选择)。全局设置,存于 `config.json` 的 `ui_language`;从未选择时不写入。 - App 切换语言时会自动设置;Desktop 每次启动也会同步一次界面实际显示的语言(App 选“跟随系统”时,发送它按系统语言解析出的语言,例如 `zh-Hans`)。全局设置,存于 `config.json` 的 `ui_language`;从未设置时不写入。
- `zh-Hans` 和 `system` 不向 SDK 传语言,SDK 照旧自动识别中文或英文;其余语言(包括 `en`)作为 `MemoryConfig.language` 传给 SDK,提取等提示使用该语言。 - 所有语言(包括 `zh-Hans` 和 `en`)都作为 `MemoryConfig.language` 传给 SDK,提取等提示使用该语言;只有未设置(`system`,或只用 CLI/API、从未打开 App 的安装)时不向 SDK 传语言,SDK 照旧自动识别中文或英文。
- 修改会像功能开关一样重建 SDK 客户端;不迁移数据,不改集合名(需要 hy-memory 2.0.11 的 `MemoryConfig.language`)。 - 修改会像功能开关一样重建 SDK 客户端;不迁移数据,不改集合名(需要 hy-memory 2.0.11 的 `MemoryConfig.language`)。
- `status --json` 的 `pipeline.ui_language` / `pipeline.sdk_language`(`auto` 或语言代码)。API:`GET`/`PUT /api/v1/settings/ui-language`。 - `status --json` 的 `pipeline.ui_language` / `pipeline.sdk_language`(`auto` 或语言代码;`zh-Hans` 用户显示 `zh-Hans`)。API:`GET`/`PUT /api/v1/settings/ui-language`。
## 8. 召回(recall) ## 8. 召回(recall)