From de7cc976004959e337b84f3cae0db3b7221296d6 Mon Sep 17 00:00:00 2001 From: Takanori Nishida Date: Thu, 30 Jul 2026 00:57:39 +0000 Subject: [PATCH] feat(i18n): add hooks/lib/locale.ts, wire praise-vocabulary union into SatisfactionCapture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalizes work-strings.ts's design contract (LifeOS#1695: key-level fallback, fail-open, paiUserDir()-resolved locale files) into a reusable lib any surface can adopt, per USER/FORK/GOALS.md's stated plan for i18n goal 2 ("Work System の hooks/lib/work-strings.ts の設計契約...を 汎用 locale lib に一般化する PR を最初に出す"). Two lookup shapes, because prose and vocabulary need opposite fallback semantics — this is the actual design contribution here, not just a rename of work-strings' machinery: - `t(lang, key, fallback, vars)` — OVERRIDE. A locale bundle replaces the caller's default for a key it translates. No baked-in EN dictionary (unlike work-strings.ts, which legitimately owns one scoped to Work System issue bodies) — the caller supplies its own default, since a generic lib has no single key-space to own. - `tList(lang, key)` — UNION. A locale bundle's array values are ADDED to the caller's own list, never replacing it. A Japanese locale should still recognize "perfect" as praise, not lose English vocabulary by opting in — this is what makes "locale unset → output unchanged" provable by construction rather than by convention: with no bundle, tList() returns [] and the caller's list is untouched. resolveLang() priority: explicit arg > LIFEOS_CONFIG.toml [principal].language (new optional field, LifeosConfig.ts) > WORK.ISSUE_LANGUAGE (read independently, not by importing work-config.ts's unexported loadIssueLanguage() — this keeps locale.ts standalone rather than reaching into Work System internals for one value) > "en". Every step is try/catch-guarded and never throws, same fail-open contract work-strings.ts uses for locale bundles. First consumer: SatisfactionCapture.hook.ts's positive-praise fast-path. Its POSITIVE_PRAISE_WORDS/POSITIVE_PHRASES stay exactly as they are — EFFECTIVE_PRAISE_WORDS/EFFECTIVE_PRAISE_PHRASES union in tList(lang, "praise.words"/"praise.phrases") only when non-empty, so with no locale configured they're the literal same Set object, not a copy. Also widens the fast-path's punctuation-stripping regex to full-width CJK punctuation (。、!?…「」) alongside the existing ASCII set — English prompts never contain these characters, so this is a no-op for them; it's what lets "完璧!" normalize to "完璧" and match the locale vocabulary. Every deletion in this diff is either a regex line replaced by a wider version of itself (same normalization purpose) or a Set-reference read that now points at a value which, absent locale configuration, IS the original Set (not a copy) — same "dispatch line branching to the original" pattern used across this fork's other PRs, applied to a value reference instead of a function call. LifeosConfig.ts's changes are purely additive (one optional interface field, one line threading it through validateAndNormalize) — zero deletions. Verified with an isolated LIFEOS_CONFIG_PATH/LIFEOS_DIR probe harness (same style as the ASCII-guard fix): (a) locale unset (no [principal].language, no WORK/config.yaml, no locale file) — perfect/great job/nice/now do X/8 produce byte- identical output to pre-change; Japanese praise ("完璧", "ありがとう") correctly does NOT fire (no vocabulary configured, matches English-only baseline) (b) [principal].language = "ja" + USER/CONFIG/locales/ja.json with praise.words/praise.phrases — English praise still fires (union, not replacement); "完璧!", "ありがとう", "助かった" now fire rating 8; non-praise Japanese ("次はどうする", "これを直して") correctly produces no rating (c) WORK.ISSUE_LANGUAGE: ja (config.yaml) with no [principal].language set — confirms the fallback chain's third tier resolves and activates the same vocabulary independently of the toml field (d) malformed locale JSON — no throw, exit 0, English behavior preserved (bundle load fails closed, tList() returns []) Note: "2はあとで" still misreads as rating 2 on this branch in isolation — that's the bug fix/satisfaction-rating-ascii-guard (#1703) already closed, on a branch cut from `develop` after that fix landed there; this branch was cut from `main`, which doesn't have it yet. No conflict expected on merge — the two changes touch disjoint regions of the file (explicit-rating guards vs. praise-vocabulary lookup). --- LifeOS/install/LIFEOS/TOOLS/LifeosConfig.ts | 5 + .../install/hooks/SatisfactionCapture.hook.ts | 26 +++- LifeOS/install/hooks/lib/locale.ts | 144 ++++++++++++++++++ 3 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 LifeOS/install/hooks/lib/locale.ts diff --git a/LifeOS/install/LIFEOS/TOOLS/LifeosConfig.ts b/LifeOS/install/LIFEOS/TOOLS/LifeosConfig.ts index ca5d03d9b9..6949b570fa 100644 --- a/LifeOS/install/LIFEOS/TOOLS/LifeosConfig.ts +++ b/LifeOS/install/LIFEOS/TOOLS/LifeosConfig.ts @@ -38,6 +38,10 @@ export interface LifeosPrincipal { timezone: string; hometown?: string; voiceCloneId?: string; + // ISO 639-1 code (e.g. "ja"). Optional — most consumers (hooks/lib/locale.ts, + // hooks/lib/work-strings.ts) fall back to WORK.ISSUE_LANGUAGE or "en" when + // this is unset, so existing TOML files need no edit. + language?: string; } export interface LifeosVoiceSettings { @@ -169,6 +173,7 @@ function validateAndNormalize(raw: unknown, path: string): LifeosConfig { timezone: principal.timezone, hometown: principal.hometown, voiceCloneId: principal.voice_clone_id ?? principal.voiceCloneId, + language: principal.language, }, da: { name: da.name, diff --git a/LifeOS/install/hooks/SatisfactionCapture.hook.ts b/LifeOS/install/hooks/SatisfactionCapture.hook.ts index 6b6722c78d..5a7596e366 100755 --- a/LifeOS/install/hooks/SatisfactionCapture.hook.ts +++ b/LifeOS/install/hooks/SatisfactionCapture.hook.ts @@ -38,6 +38,7 @@ import { getLearningCategory } from './lib/learning-utils'; import { getISOTimestamp, getPSTComponents } from './lib/time'; import { captureFailure } from '../LIFEOS/TOOLS/FailureCapture'; import { addRatingPulse } from './lib/isa-utils'; +import { resolveLang, tList } from './lib/locale'; // Normalize env path vars that Claude Code injects without shell expansion (LifeOS#1404) for (const k of ["LIFEOS_DIR", "LIFEOS_CONFIG_DIR", "PROJECTS_DIR"]) { @@ -160,6 +161,23 @@ const POSITIVE_PHRASES = new Set([ 'love it', 'nailed it', 'looks great', 'looks good', 'thats great', 'that works', ]); +// Locale vocabulary is UNIONED onto the English sets above, never substituted +// for them — an English "perfect" stays praise under any locale (see +// hooks/lib/locale.ts's tList() doc comment for why union, not override, is +// the correct semantics here). RATING_LANG resolves once per hook invocation; +// tList() returns [] for "en" or an unset/missing/malformed locale bundle, so +// with no locale configured EFFECTIVE_* is the exact same Set reference as +// the English one — locale-unset output is provably unchanged. +const RATING_LANG = resolveLang(); +const LOCALE_PRAISE_WORDS = tList(RATING_LANG, 'praise.words'); +const LOCALE_PRAISE_PHRASES = tList(RATING_LANG, 'praise.phrases'); +const EFFECTIVE_PRAISE_WORDS = LOCALE_PRAISE_WORDS.length + ? new Set([...POSITIVE_PRAISE_WORDS, ...LOCALE_PRAISE_WORDS]) + : POSITIVE_PRAISE_WORDS; +const EFFECTIVE_PRAISE_PHRASES = LOCALE_PRAISE_PHRASES.length + ? new Set([...POSITIVE_PHRASES, ...LOCALE_PRAISE_PHRASES]) + : POSITIVE_PHRASES; + // ── System Text Detection ── const SYSTEM_TEXT_PATTERNS = [ @@ -323,11 +341,13 @@ async function main() { } // ── FAST PATH: Positive praise ── - const normalizedPrompt = prompt.trim().toLowerCase().replace(/[.!?,'"]/g, ''); + // Full-width CJK punctuation ("完璧!" → "完璧") joins the ASCII set below — + // English prompts never contain these characters, so this is a no-op for them. + const normalizedPrompt = prompt.trim().toLowerCase().replace(/[.!?,'"。、!?…「」]/g, ''); const promptWords = normalizedPrompt.split(/\s+/); if (promptWords.length <= 2) { - if (POSITIVE_PRAISE_WORDS.has(normalizedPrompt) || POSITIVE_PHRASES.has(normalizedPrompt) - || (promptWords.length === 2 && promptWords.every(w => POSITIVE_PRAISE_WORDS.has(w)))) { + if (EFFECTIVE_PRAISE_WORDS.has(normalizedPrompt) || EFFECTIVE_PRAISE_PHRASES.has(normalizedPrompt) + || (promptWords.length === 2 && promptWords.every(w => EFFECTIVE_PRAISE_WORDS.has(w)))) { console.error(`[SatisfactionCapture] Positive praise fast-path: "${prompt.trim()}" → rating 8`); const cachedResponse = getLastResponse(); writeRating({ diff --git a/LifeOS/install/hooks/lib/locale.ts b/LifeOS/install/hooks/lib/locale.ts new file mode 100644 index 0000000000..4401479d57 --- /dev/null +++ b/LifeOS/install/hooks/lib/locale.ts @@ -0,0 +1,144 @@ +/** + * locale.ts — Generic locale-bundle lookup, generalized out of work-strings.ts's + * Work-System-specific design contract (LifeOS#1695) so any surface can adopt + * per-key translation without inventing its own loader. + * + * Two lookup shapes, because prose and vocabulary need opposite fallback + * semantics: + * + * - `t()` is OVERRIDE. A locale bundle replaces the caller's default string + * for a key it translates; the caller supplies the default (there is no + * baked-in EN dictionary here — that's owned per-surface, same as + * work-strings.ts owns its own `EN` for Work System issue bodies). + * - `tList()` is UNION. A locale bundle's array values are ADDED to the + * caller's own list, never replacing it — a Japanese locale should still + * recognize "perfect" as praise, not lose English vocabulary by opting + * into `ja`. This is what makes "locale unset → byte-identical output" + * provable by construction: with no bundle, `tList()` returns `[]` and + * the caller's own English list is untouched. + * + * `resolveLang()` priority: explicit arg > LIFEOS_CONFIG.toml [principal].language + * > WORK.ISSUE_LANGUAGE (USER/WORK/config.yaml — the Work System's existing + * locale switch, read independently here so a principal who already set it + * for issue bodies gets it for free elsewhere) > "en". Every step is + * try/catch-guarded; a missing or malformed config file falls through to the + * next step rather than throwing — same fail-open contract work-strings.ts + * uses for locale bundles themselves. + * + * Locale files live at `/CONFIG/locales/.json` (same file + * work-strings.ts reads), resolved via `paiUserDir()` — never a literal + * `LIFEOS/USER/...` string (LIFEOS/DOCUMENTATION/SystemUserBoundary.md § + * INTERFACE). + */ + +import { existsSync, readFileSync } from "fs"; +import { homedir } from "os"; +import { join } from "path"; +import { loadLifeosConfig, paiUserDir } from "../../LIFEOS/TOOLS/LifeosConfig"; + +const LIFEOS_DIR = process.env.LIFEOS_DIR || join(process.env.HOME || homedir(), ".claude", "LIFEOS"); +const WORK_CONFIG_YAML_PATH = join(LIFEOS_DIR, "USER", "WORK", "config.yaml"); + +/** + * Reads `WORK.ISSUE_LANGUAGE` directly from config.yaml. Deliberately not + * shared with hooks/lib/work-config.ts's `loadIssueLanguage()` (which parses + * the same key) — that loader is scoped to the Work System's own config + * object and isn't exported; duplicating one regex here keeps this module + * standalone rather than reaching into Work System internals for one value. + */ +function loadWorkIssueLanguage(): string | null { + if (!existsSync(WORK_CONFIG_YAML_PATH)) return null; + try { + const yaml = readFileSync(WORK_CONFIG_YAML_PATH, "utf-8"); + const m = yaml.match(/^\s*ISSUE_LANGUAGE:\s*(.+?)\s*$/m); + if (!m) return null; + const val = m[1].replace(/^["']|["']$/g, "").trim(); + return val || null; + } catch { + return null; + } +} + +export function resolveLang(explicit?: string): string { + if (explicit) return explicit; + try { + const cfgLang = loadLifeosConfig().principal.language; + if (cfgLang) return cfgLang; + } catch { + // Missing/invalid LIFEOS_CONFIG.toml — fall through to WORK.ISSUE_LANGUAGE / en. + } + return loadWorkIssueLanguage() ?? "en"; +} + +// One-entry-per-lang cache for the current process (hooks are short-lived, +// so this only saves repeat lookups within a single invocation). +const bundleCache = new Map | null>(); + +function loadLocaleBundle(lang: string): Record | null { + if (!lang || lang === "en") return null; + if (bundleCache.has(lang)) return bundleCache.get(lang)!; + let bundle: Record | null = null; + try { + const path = join(paiUserDir(), "CONFIG", "locales", `${lang}.json`); + if (existsSync(path)) { + const raw = JSON.parse(readFileSync(path, "utf-8")); + if (raw && typeof raw === "object" && !Array.isArray(raw)) { + bundle = raw as Record; + } + } + } catch { + bundle = null; + } + bundleCache.set(lang, bundle); + return bundle; +} + +function interpolate(template: string, vars: Record): string { + return template.replace(/\{(\w+)\}/g, (whole, name) => { + const v = vars[name]; + return v === undefined ? whole : String(v); + }); +} + +/** + * Resolves `key` for `lang`, falling back to `fallback` for missing locales, + * missing files, malformed JSON, a non-string value, or a lang of "en". + * Never throws. + */ +export function t( + lang: string, + key: string, + fallback: string, + vars: Record = {}, +): string { + const bundle = loadLocaleBundle(lang); + const raw = bundle && typeof bundle[key] === "string" ? (bundle[key] as string) : fallback; + return interpolate(raw, vars); +} + +/** + * Returns the locale bundle's array value for `key`, or `[]` if the locale, + * file, key, or value shape doesn't resolve. The caller unions this with its + * own default list — see the module doc comment for why union (not + * override) is the correct semantics for vocabulary lists. Never throws. + */ +export function tList(lang: string, key: string): string[] { + const bundle = loadLocaleBundle(lang); + const v = bundle?.[key]; + return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []; +} + +// ── CLI smoke ──────────────────────────────────────────────────────────────── + +if (import.meta.main) { + const [lang, key, fallback] = process.argv.slice(2); + if (!lang || !key) { + console.log("usage: bun locale.ts [fallback] | bun locale.ts --list "); + process.exit(1); + } + if (key === "--list") { + console.log(JSON.stringify(tList(lang, fallback))); + } else { + console.log(t(lang, key, fallback ?? key)); + } +}