zigmini 0.9.0-beta.3 → 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.ko.md +24 -4
  2. package/README.md +24 -4
  3. package/assets/agent/gateway-rules.md +1 -1
  4. package/assets/android/zigmini.apk +0 -0
  5. package/assets/android/zigmini.apk.json +3 -3
  6. package/assets/build.json +1 -1
  7. package/docs/manual.ko.md +51 -11
  8. package/docs/manual.md +52 -12
  9. package/host/omo-bridge/agent-control.ts +274 -0
  10. package/host/omo-bridge/bridge.ts +1 -1
  11. package/host/omo-bridge/continuity-control.ts +1 -1
  12. package/host/omo-bridge/task-control.ts +26 -16
  13. package/host/omo-check/patches/omo-5.1.51-compaction-long-context.patch +92 -0
  14. package/host/omo-check/patches/omo-5.1.51-compaction-port.patch +2635 -0
  15. package/host/omo-check/patches/omo-5.1.51-compaction-recovery.patch +911 -0
  16. package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +363 -0
  17. package/host/omo-check/patches/omo-5.1.51-provider-failover.patch +50 -0
  18. package/host/omo-check/patches/omo-5.1.51-provider-port.patch +441 -0
  19. package/host/omo-check/patches/omo-5.1.51-rpc-port.patch +799 -0
  20. package/host/omo-check/patches/omo-5.1.51-runtime-compat.patch +31 -0
  21. package/package.json +3 -2
  22. package/src/agent/api.ts +215 -0
  23. package/src/agent/definition.ts +177 -0
  24. package/src/agent/fields.ts +42 -0
  25. package/src/agent/files.ts +192 -0
  26. package/src/agent/legacy-files.ts +46 -0
  27. package/src/agent/list.ts +21 -0
  28. package/src/agent-rules.ts +14 -5
  29. package/src/approvals.ts +6 -1
  30. package/src/cli/main.ts +33 -0
  31. package/src/cli/messages.ts +10 -2
  32. package/src/cli/rotate-main.ts +2 -1
  33. package/src/cli/scan.ts +414 -0
  34. package/src/cli/security-import.ts +155 -0
  35. package/src/compact.ts +22 -0
  36. package/src/decisions.ts +6 -4
  37. package/src/engine/compat.json +3 -3
  38. package/src/engine/index.ts +1 -0
  39. package/src/engine/schema-guard.ts +12 -4
  40. package/src/engine/sdk.ts +8 -0
  41. package/src/formal-verification.ts +366 -0
  42. package/src/gc.ts +37 -6
  43. package/src/hub.ts +339 -79
  44. package/src/main.ts +95 -9
  45. package/src/maintenance.ts +1 -1
  46. package/src/manual.ts +9 -2
  47. package/src/profile-prompt.ts +73 -0
  48. package/src/profile.ts +33 -10
  49. package/src/protocol.ts +11 -2
  50. package/src/question-notifications.ts +4 -1
  51. package/src/reply-notifications.ts +7 -1
  52. package/src/repo-security-setting.ts +33 -0
  53. package/src/runtime/paths.ts +16 -0
  54. package/src/security/baseline.ts +170 -0
  55. package/src/security/dex-strings.ts +242 -0
  56. package/src/security/repo-scan.ts +369 -0
  57. package/src/security/revision-probe.ts +144 -0
  58. package/src/security/terms.ts +97 -0
  59. package/src/security-allow.ts +1 -0
  60. package/src/security-baseline.ts +113 -0
  61. package/src/security-check.ts +91 -16
  62. package/src/server-text.ts +38 -0
  63. package/src/store.ts +142 -16
  64. package/src/transcript.ts +7 -1
  65. package/src/ui-language.ts +33 -0
  66. package/src/update/check.ts +7 -3
  67. package/src/update/handoff-system.ts +24 -16
  68. package/src/update/handoff.ts +23 -12
  69. package/src/update/helper.ts +72 -36
  70. package/src/update/request.ts +46 -30
  71. package/src/update/state.ts +21 -11
  72. package/src/update/strategy.ts +12 -5
  73. package/src/update/target.ts +32 -15
  74. package/src/usage.ts +43 -20
  75. package/src/version.ts +39 -16
  76. package/src/voice/agents.ts +8 -3
  77. package/src/voice/install.ts +29 -18
  78. package/src/work-watch.ts +40 -18
  79. package/web/agent-icon.js +15 -9
  80. package/web/agents.d.ts +201 -0
  81. package/web/agents.js +813 -0
  82. package/web/app-download.js +32 -19
  83. package/web/app.js +1407 -278
  84. package/web/autocopy.js +6 -1
  85. package/web/boot.js +27 -8
  86. package/web/commands.js +20 -11
  87. package/web/decisions.js +70 -49
  88. package/web/devlog.js +25 -19
  89. package/web/diagram.js +8 -3
  90. package/web/enroll.js +43 -22
  91. package/web/gc.js +32 -19
  92. package/web/i18n/agent-icon.js +24 -0
  93. package/web/i18n/agents.js +48 -0
  94. package/web/i18n/app-download.js +26 -0
  95. package/web/i18n/autocopy.js +5 -0
  96. package/web/i18n/boot.js +24 -0
  97. package/web/i18n/commands.js +34 -0
  98. package/web/i18n/decisions.js +24 -0
  99. package/web/i18n/devlog.js +60 -0
  100. package/web/i18n/diagram.js +12 -0
  101. package/web/i18n/enroll.js +22 -0
  102. package/web/i18n/gc.js +38 -0
  103. package/web/i18n/lastsent.js +10 -0
  104. package/web/i18n/main.d.ts +4 -0
  105. package/web/i18n/main.js +527 -0
  106. package/web/i18n/manual.d.ts +4 -0
  107. package/web/i18n/manual.js +14 -0
  108. package/web/i18n/memo.js +30 -0
  109. package/web/i18n/notifications.js +16 -0
  110. package/web/i18n/profile.js +13 -0
  111. package/web/i18n/reference.js +26 -0
  112. package/web/i18n/security-inbox.js +40 -0
  113. package/web/i18n/slash.js +38 -0
  114. package/web/i18n/turn-stats.js +5 -0
  115. package/web/i18n/update.js +42 -0
  116. package/web/i18n/voice.js +28 -0
  117. package/web/i18n/wake.js +16 -0
  118. package/web/i18n/workid.js +10 -0
  119. package/web/i18n/workstatus.js +20 -0
  120. package/web/i18n.d.ts +20 -0
  121. package/web/i18n.js +63 -0
  122. package/web/index.html +156 -96
  123. package/web/lastsent.js +9 -4
  124. package/web/manual.d.ts +1 -1
  125. package/web/manual.js +11 -5
  126. package/web/memo.js +18 -13
  127. package/web/notifications.js +12 -7
  128. package/web/profile.js +42 -17
  129. package/web/reference.js +20 -15
  130. package/web/security-inbox.d.ts +18 -1
  131. package/web/security-inbox.js +173 -4
  132. package/web/slash.js +23 -13
  133. package/web/style.css +132 -3
  134. package/web/tooltip.js +5 -2
  135. package/web/turn-stats.js +6 -2
  136. package/web/update.js +31 -23
  137. package/web/voice.js +18 -8
  138. package/web/wake.d.ts +4 -0
  139. package/web/wake.js +7 -3
  140. package/web/workid.js +12 -2
  141. package/web/workstatus.js +36 -32
@@ -1,6 +1,7 @@
1
1
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { PROTOCOL_VERSION, type Envelope } from "../protocol.ts";
4
+ import { say, type Text } from "../server-text.ts";
4
5
 
5
6
  /**
6
7
  * The files the update request (gateway) and the update helper (one-shot launchd job) share under `<home>/update/`
@@ -23,18 +24,27 @@ export const STALE_MS = 20 * 60_000;
23
24
 
24
25
  export type Phase = "queued" | "pack" | "install" | "consent" | "engine" | "restart" | "health" | "rollback" | "done";
25
26
 
26
- export const PHASE_LABELS: Readonly<Record<Phase, string>> = {
27
- queued: "업데이트 도우미를 시작하는 중",
28
- pack: "지금 버전을 되돌리기용으로 저장하는 중",
29
- install: "새 버전을 설치하는 중",
30
- consent: "권한 동의 기록을 바꾸는 중",
31
- engine: "새 엔진을 설치하는 중",
32
- restart: "게이트웨이와 브리지를 다시 시작하는 중",
33
- health: "새 버전의 상태를 확인하는 중",
34
- rollback: "실패해 이전 버전으로 되돌리는 중",
35
- done: "끝남",
27
+ const PHASE_TEXTS: Readonly<Record<Phase, Text>> = {
28
+ queued: { en: "Starting the update helper", ko: "업데이트 도우미를 시작하는 중" },
29
+ pack: { en: "Saving the current version for rollback", ko: "지금 버전을 되돌리기용으로 저장하는 중" },
30
+ install: { en: "Installing the new version", ko: "새 버전을 설치하는 중" },
31
+ consent: { en: "Updating the permission consent record", ko: "권한 동의 기록을 바꾸는 중" },
32
+ engine: { en: "Installing the new engine", ko: "새 엔진을 설치하는 중" },
33
+ restart: { en: "Restarting the gateway and bridge", ko: "게이트웨이와 브리지를 다시 시작하는 중" },
34
+ health: { en: "Checking the health of the new version", ko: "새 버전의 상태를 확인하는 중" },
35
+ rollback: { en: "Update failed; rolling back to the previous version", ko: "실패해 이전 버전으로 되돌리는 중" },
36
+ done: { en: "Done", ko: "끝남" },
36
37
  };
37
38
 
39
+ /** The Korean step labels, for the terminal command (src/update/cli.ts, out of the server-text scope). */
40
+ export const PHASE_LABELS: Readonly<Record<Phase, string>> = Object.fromEntries(
41
+ Object.entries(PHASE_TEXTS).map(([phase, text]) => [phase, text.ko])) as Record<Phase, string>;
42
+
43
+ /** The update progress label of a step in the saved UI language, made when it is asked for. */
44
+ export function phaseLabel(phase: Phase): string {
45
+ return say(PHASE_TEXTS[phase]);
46
+ }
47
+
38
48
  export type Origin = "web" | "app" | "cli";
39
49
 
40
50
  export interface UpdateRequest {
@@ -127,7 +137,7 @@ export function updateRunning(state: UpdateState | null, now: number): boolean {
127
137
 
128
138
  export function progressEnvelope(state: UpdateState, now = new Date()): Envelope<typeof UPDATE_PROGRESS, { id: string; from: string; to: string; phase: Phase; label: string }> {
129
139
  return { v: PROTOCOL_VERSION, id: `up${now.getTime().toString(36)}`, ts: now.toISOString(), conv: "*", type: UPDATE_PROGRESS,
130
- data: { id: state.id, from: state.from, to: state.to, phase: state.phase, label: PHASE_LABELS[state.phase] } };
140
+ data: { id: state.id, from: state.from, to: state.to, phase: state.phase, label: phaseLabel(state.phase) } };
131
141
  }
132
142
 
133
143
  export function resultEnvelope(id: string, result: UpdateResult, now = new Date()): Envelope<typeof UPDATE_RESULT, UpdateResult & { id: string }> {
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { say, type Text } from "../server-text.ts";
3
4
  import type { UpdateRequest } from "./state.ts";
4
5
 
5
6
  /**
@@ -12,6 +13,12 @@ import type { UpdateRequest } from "./state.ts";
12
13
 
13
14
  export type RestartTarget = "bridge" | "gateway";
14
15
 
16
+ const RESTART_FAILED: Text = { en: "Restarting {target} failed: {error}", ko: "{target} 재시작 실패: {error}" };
17
+ const NO_REQUEST: Text = { en: "There is no update request", ko: "업데이트 요청이 없습니다" };
18
+ const HANDOFF_FAILED_TEXT: Text = { en: "Generation handoff failed", ko: "세대 전환 실패" };
19
+ const HANDOFF_UNREADABLE: Text = { en: "Generation handoff failed (could not read the result)", ko: "세대 전환 실패(결과를 읽지 못함)" };
20
+ const HANDOFF_NO_RESULT: Text = { en: "The generation handoff command ended without a result (exit code {code})", ko: "세대 전환 명령이 결과 없이 끝났습니다(종료 코드 {code})" };
21
+
15
22
  export interface StrategyContext {
16
23
  /** Restarts one launchd agent; resolves to an error text, or null on success. */
17
24
  readonly restart: (target: RestartTarget) => Promise<string | null>;
@@ -38,7 +45,7 @@ export const restartAll: UpdateStrategy = {
38
45
  async run(ctx) {
39
46
  for (const target of ["bridge", "gateway"] as const) {
40
47
  const error = await ctx.restart(target);
41
- if (error !== null) return `${target} 재시작 실패: ${error}`;
48
+ if (error !== null) return say(RESTART_FAILED, { target, error });
42
49
  ctx.log(`재시작함: ${target}`);
43
50
  }
44
51
  return null;
@@ -70,7 +77,7 @@ export function externalHandoff(spawn: (requestId: string) => HandoffProcess): U
70
77
  return {
71
78
  name: HANDOFF_STRATEGY,
72
79
  async run(ctx) {
73
- if (!ctx.request) return "업데이트 요청이 없습니다";
80
+ if (!ctx.request) return say(NO_REQUEST);
74
81
  const proc = spawn(ctx.request.id);
75
82
  child = proc;
76
83
  for await (const line of proc.lines) {
@@ -79,14 +86,14 @@ export function externalHandoff(spawn: (requestId: string) => HandoffProcess): U
79
86
  if (line.startsWith(HANDOFF_FAILED)) {
80
87
  try {
81
88
  const failure = JSON.parse(line.slice(HANDOFF_FAILED.length)) as { untouched?: unknown; message?: unknown };
82
- const message = typeof failure.message === "string" ? failure.message : "세대 전환 실패";
89
+ const message = typeof failure.message === "string" ? failure.message : say(HANDOFF_FAILED_TEXT);
83
90
  return failure.untouched === true ? { untouched: true, message } : message;
84
91
  } catch {
85
- return "세대 전환 실패(결과를 읽지 못함)";
92
+ return say(HANDOFF_UNREADABLE);
86
93
  }
87
94
  }
88
95
  }
89
- return `세대 전환 명령이 결과 없이 끝났습니다(종료 코드 ${await proc.exited})`;
96
+ return say(HANDOFF_NO_RESULT, { code: await proc.exited });
90
97
  },
91
98
  async settle() {
92
99
  if (child !== null) await child.exited;
@@ -3,6 +3,7 @@ import { mkdirSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { parseNotice, type PermissionNotice } from "../consent/consent.ts";
5
5
  import type { Env } from "../runtime/paths.ts";
6
+ import { say, type Text } from "../server-text.ts";
6
7
  import { parseVersion, registryUrl } from "./check.ts";
7
8
  import { HANDOFF_STRATEGY } from "./strategy.ts";
8
9
  import { engineRevision } from "../engine/build-id.ts";
@@ -28,6 +29,22 @@ export interface TargetInfo {
28
29
 
29
30
  export class TargetError extends Error {}
30
31
 
32
+ const NO_PACKAGE_JSON: Text = { en: "The target package has no package.json", ko: "대상 패키지에 package.json이 없습니다" };
33
+ const PACKAGE_JSON_UNREADABLE: Text = { en: "Could not read the target package's package.json", ko: "대상 패키지의 package.json을 읽지 못했습니다" };
34
+ const NOT_ZIGMINI: Text = { en: "The target package is not zigmini", ko: "대상 패키지가 zigmini가 아닙니다" };
35
+ const BAD_VERSION: Text = { en: "The target package's version is not valid", ko: "대상 패키지의 버전이 올바르지 않습니다" };
36
+ const NO_NOTICE: Text = { en: "The target package has no permission notice", ko: "대상 패키지에 권한 안내문이 없습니다" };
37
+ const BAD_REVISION: Text = { en: "The target package's engine revision is not valid", ko: "대상 패키지의 엔진 리비전이 올바르지 않습니다" };
38
+ const NOT_A_VERSION: Text = { en: "Not a version format: {requested}", ko: "버전 형식이 아닙니다: {requested}" };
39
+ const SOURCE_MISMATCH: Text = { en: "Version {actual} of ZIGMINI_UPDATE_SOURCE differs from the requested {requested}", ko: "ZIGMINI_UPDATE_SOURCE의 버전 {actual}이 요청 {requested}와 다릅니다" };
40
+ const REGISTRY_UNREACHABLE: Text = { en: "Could not connect to the registry: {error}", ko: "레지스트리에 연결하지 못했습니다: {error}" };
41
+ const REGISTRY_HTTP: Text = { en: "Registry HTTP {status}", ko: "레지스트리 HTTP {status}" };
42
+ const NO_LATEST: Text = { en: "The registry has no latest version", ko: "레지스트리에 latest 버전이 없습니다" };
43
+ const NO_SUCH_VERSION: Text = { en: "The registry has no {version}", ko: "레지스트리에 {version}이 없습니다" };
44
+ const DOWNLOAD_FAILED: Text = { en: "Could not download the {version} package", ko: "{version} 패키지를 받지 못했습니다" };
45
+ const BAD_INTEGRITY: Text = { en: "The integrity value of the {version} package does not match", ko: "{version} 패키지의 무결성 값이 맞지 않습니다" };
46
+ const DOWNLOAD_MISMATCH: Text = { en: "The downloaded package's version {actual} differs from {version}", ko: "받은 패키지의 버전 {actual}이 {version}과 다릅니다" };
47
+
31
48
  export interface TargetDeps {
32
49
  readonly env: Env;
33
50
  readonly fetch: typeof fetch;
@@ -38,27 +55,27 @@ export interface TargetDeps {
38
55
  }
39
56
 
40
57
  export function parseTarget(pkgText: string | null, compatText: string | null, source: string, manifestText?: string | null): TargetInfo {
41
- if (pkgText === null) throw new TargetError("대상 패키지에 package.json이 없습니다");
58
+ if (pkgText === null) throw new TargetError(say(NO_PACKAGE_JSON));
42
59
  let pkg: { name?: unknown; version?: unknown; zigmini?: { permissionNotice?: unknown; updateStrategies?: unknown } };
43
60
  try {
44
61
  pkg = JSON.parse(pkgText);
45
62
  } catch {
46
- throw new TargetError("대상 패키지의 package.json을 읽지 못했습니다");
63
+ throw new TargetError(say(PACKAGE_JSON_UNREADABLE));
47
64
  }
48
- if (pkg.name !== "zigmini") throw new TargetError("대상 패키지가 zigmini가 아닙니다");
49
- if (typeof pkg.version !== "string" || parseVersion(pkg.version) === null) throw new TargetError("대상 패키지의 버전이 올바르지 않습니다");
65
+ if (pkg.name !== "zigmini") throw new TargetError(say(NOT_ZIGMINI));
66
+ if (typeof pkg.version !== "string" || parseVersion(pkg.version) === null) throw new TargetError(say(BAD_VERSION));
50
67
  let notice: PermissionNotice;
51
68
  try {
52
69
  notice = parseNotice(pkg.zigmini?.permissionNotice);
53
70
  } catch {
54
- throw new TargetError("대상 패키지에 권한 안내문이 없습니다");
71
+ throw new TargetError(say(NO_NOTICE));
55
72
  }
56
73
  let engine = parseEnginePin(compatText);
57
74
  if (engine !== null && manifestText != null) {
58
75
  try {
59
76
  engine = { ...engine, revision: engineRevision(JSON.parse(manifestText).revision) };
60
77
  } catch {
61
- throw new TargetError("대상 패키지의 엔진 리비전이 올바르지 않습니다");
78
+ throw new TargetError(say(BAD_REVISION));
62
79
  }
63
80
  }
64
81
  const strategies = pkg.zigmini?.updateStrategies;
@@ -91,32 +108,32 @@ export function integrityMatches(bytes: Uint8Array, integrity: string): boolean
91
108
  }
92
109
 
93
110
  export async function resolveTarget(requested: string, deps: TargetDeps): Promise<TargetInfo> {
94
- if (requested !== "latest" && parseVersion(requested) === null) throw new TargetError(`버전 형식이 아닙니다: ${requested}`);
111
+ if (requested !== "latest" && parseVersion(requested) === null) throw new TargetError(say(NOT_A_VERSION, { requested }));
95
112
  const local = deps.env.ZIGMINI_UPDATE_SOURCE;
96
113
  if (local) {
97
114
  const target = inspect(local, local, deps);
98
- if (requested !== "latest" && requested !== target.version) throw new TargetError(`ZIGMINI_UPDATE_SOURCE의 버전 ${target.version}이 요청 ${requested}와 다릅니다`);
115
+ if (requested !== "latest" && requested !== target.version) throw new TargetError(say(SOURCE_MISMATCH, { actual: target.version, requested }));
99
116
  return target;
100
117
  }
101
118
  const url = registryUrl(deps.env);
102
119
  const res = await deps.fetch(url, { signal: AbortSignal.timeout(15_000) }).catch((error: unknown) => {
103
- throw new TargetError(`레지스트리에 연결하지 못했습니다: ${error instanceof Error ? error.message : String(error)}`);
120
+ throw new TargetError(say(REGISTRY_UNREACHABLE, { error: error instanceof Error ? error.message : String(error) }));
104
121
  });
105
- if (!res.ok) throw new TargetError(`레지스트리 HTTP ${res.status}`);
122
+ if (!res.ok) throw new TargetError(say(REGISTRY_HTTP, { status: res.status }));
106
123
  const doc = await res.json().catch(() => null) as { "dist-tags"?: { latest?: unknown }; versions?: Record<string, { dist?: { tarball?: unknown; integrity?: unknown } }> } | null;
107
124
  const version = requested === "latest" ? doc?.["dist-tags"]?.latest : requested;
108
- if (typeof version !== "string" || parseVersion(version) === null) throw new TargetError("레지스트리에 latest 버전이 없습니다");
125
+ if (typeof version !== "string" || parseVersion(version) === null) throw new TargetError(say(NO_LATEST));
109
126
  const dist = doc?.versions?.[version]?.dist;
110
- if (typeof dist?.tarball !== "string" || typeof dist.integrity !== "string") throw new TargetError(`레지스트리에 ${version}이 없습니다`);
127
+ if (typeof dist?.tarball !== "string" || typeof dist.integrity !== "string") throw new TargetError(say(NO_SUCH_VERSION, { version }));
111
128
  const download = await deps.fetch(dist.tarball, { signal: AbortSignal.timeout(120_000) }).catch(() => null);
112
- if (download === null || !download.ok) throw new TargetError(`${version} 패키지를 받지 못했습니다`);
129
+ if (download === null || !download.ok) throw new TargetError(say(DOWNLOAD_FAILED, { version }));
113
130
  const bytes = new Uint8Array(await download.arrayBuffer());
114
- if (!integrityMatches(bytes, dist.integrity)) throw new TargetError(`${version} 패키지의 무결성 값이 맞지 않습니다`);
131
+ if (!integrityMatches(bytes, dist.integrity)) throw new TargetError(say(BAD_INTEGRITY, { version }));
115
132
  mkdirSync(deps.dir, { recursive: true });
116
133
  const file = join(deps.dir, `target-${version}.tgz`);
117
134
  writeFileSync(file, bytes);
118
135
  const target = inspect(file, `zigmini@${version}`, deps);
119
- if (target.version !== version) throw new TargetError(`받은 패키지의 버전 ${target.version}이 ${version}과 다릅니다`);
136
+ if (target.version !== version) throw new TargetError(say(DOWNLOAD_MISMATCH, { actual: target.version, version }));
120
137
  return target;
121
138
  }
122
139
 
package/src/usage.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { say, type Text } from "./server-text.ts";
3
4
 
4
5
  /**
5
6
  * `/usage` (improvement 30): the gateway answers it with a notice and never forwards it to omo. It reads the agent
@@ -7,9 +8,33 @@ import { join } from "node:path";
7
8
  * and asks each account's provider usage API in parallel. Tokens, emails and account ids never leave this module:
8
9
  * the report names an account by its slot name only.
9
10
  */
10
- export const USAGE_COMMAND = { name: "usage", description: "로그인한 모델 계정 사용량 보기 (게이트웨이가 바로 답함)", source: "extension" };
11
-
12
- export const USAGE_UNAVAILABLE = "이 실행 방식에서는 쓸 수 없습니다(이전 뒤 사용)";
11
+ const USAGE_DESCRIPTION: Text = { en: "View usage for signed-in model accounts (answered by the gateway)", ko: "로그인한 모델 계정 사용량 보기 (게이트웨이가 바로 답함)" };
12
+ export const USAGE_COMMAND = { name: "usage", get description() { return say(USAGE_DESCRIPTION); }, source: "extension" };
13
+
14
+ const USAGE_UNAVAILABLE_TEXT: Text = { en: "Unavailable in this run mode (use after migration)", ko: "이 실행 방식에서는 쓸 수 없습니다(이전 뒤 사용)" };
15
+ export const USAGE_UNAVAILABLE: Text = USAGE_UNAVAILABLE_TEXT;
16
+
17
+ const USAGE_TEXT = {
18
+ title: { en: "Account usage", ko: "계정 사용량" },
19
+ noAccounts: { en: "No signed-in model accounts.", ko: "로그인한 모델 계정이 없습니다." },
20
+ emptyAuth: { en: "No signed-in model accounts (auth.json is missing from the agent folder).", ko: "로그인한 모델 계정이 없습니다 (에이전트 폴더에 auth.json 없음)." },
21
+ brokenAuth: { en: "Could not read auth.json (invalid JSON).", ko: "auth.json을 읽지 못했습니다 (JSON 형식 오류)." },
22
+ account: { en: "· {slot} ({provider}){pinned}", ko: "· {slot} ({provider}){pinned}" },
23
+ pinned: { en: " [pinned]", ko: " [고정]" },
24
+ remaining: { en: "{label} remaining {percent}%", ko: "{label} 남음 {percent}%" },
25
+ period: { en: "Period", ko: "기간" },
26
+ reset: { en: " (resets {time})", ko: " (초기화 {time})" },
27
+ lastUsed: { en: " · Last used {time}", ko: " · 최근 사용 {time}" },
28
+ failed: { en: "Lookup failed ({detail})", ko: "조회 실패 ({detail})" },
29
+ unknown: { en: "Unknown", ko: "알 수 없음" },
30
+ expired: { en: "Token expired (the engine will refresh it on next use)", ko: "토큰 만료 (엔진이 다음 사용 때 갱신)" },
31
+ unsupported: { en: "Usage API unavailable", ko: "사용량 API 없음" },
32
+ noToken: { en: "No token", ko: "토큰 없음" },
33
+ http: { en: "HTTP {status}", ko: "HTTP {status}" },
34
+ noUsage: { en: "No usage in response", ko: "응답에 사용량 없음" },
35
+ timeout: { en: "Timed out", ko: "시간 초과" },
36
+ connection: { en: "Connection failed", ko: "연결 실패" },
37
+ } satisfies Record<string, Text>;
13
38
 
14
39
  export function isUsageCommand(text: string): boolean {
15
40
  return /^\/usage$/i.test(text.trim());
@@ -107,7 +132,7 @@ function readAccounts(auth: unknown): Account[] {
107
132
  accounts.push({
108
133
  provider,
109
134
  name,
110
- slot: name.includes("@") ? `계정 ${index + 1}` : name,
135
+ slot: name.includes("@") ? say({ en: "Account {number}", ko: "계정 {number}" }, { number: index + 1 }) : name,
111
136
  pinned: text(entry.pinned) === name,
112
137
  kind: Object.hasOwn(USAGE_PROVIDERS, provider) ? USAGE_PROVIDERS[provider]! : null,
113
138
  access,
@@ -248,19 +273,19 @@ async function lookup(account: Account, now: number, deps: UsageDeps): Promise<U
248
273
  const base = { provider: account.provider, slot: account.slot, pinned: account.pinned, windows: [] };
249
274
  if (account.kind === null) return { ...base, status: "unsupported" };
250
275
  if (account.expires !== null && account.expires <= now) return { ...base, status: "expired" };
251
- if (account.access === null) return { ...base, status: "failed", detail: "토큰 없음" };
276
+ if (account.access === null) return { ...base, status: "failed", detail: say(USAGE_TEXT.noToken) };
252
277
  try {
253
278
  const response = await (deps.fetch ?? fetch)((deps.endpoints ?? USAGE_ENDPOINTS)[account.kind], {
254
279
  headers: headersFor(account),
255
280
  redirect: "error",
256
281
  signal: AbortSignal.timeout(deps.timeoutMs ?? 10_000),
257
282
  });
258
- if (!response.ok) return { ...base, status: "failed", detail: `HTTP ${response.status}` };
283
+ if (!response.ok) return { ...base, status: "failed", detail: say(USAGE_TEXT.http, { status: response.status }) };
259
284
  const windows = windowsOf(account.kind, await response.json().catch(() => undefined), now);
260
- return windows.length > 0 ? { ...base, status: "ok", windows } : { ...base, status: "failed", detail: "응답에 사용량 없음" };
285
+ return windows.length > 0 ? { ...base, status: "ok", windows } : { ...base, status: "failed", detail: say(USAGE_TEXT.noUsage) };
261
286
  } catch (error) {
262
287
  const timedOut = error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
263
- return { ...base, status: "failed", detail: timedOut ? "시간 초과" : "연결 실패" };
288
+ return { ...base, status: "failed", detail: say(timedOut ? USAGE_TEXT.timeout : USAGE_TEXT.connection) };
264
289
  }
265
290
  }
266
291
 
@@ -279,19 +304,17 @@ function stamp(ms: number, timeZone: string | undefined): string {
279
304
  return `${part("month")}/${part("day")} ${part("hour")}:${part("minute")}`;
280
305
  }
281
306
 
282
- const STATUS_TEXT = { expired: "토큰 만료 (엔진이 다음 사용 때 갱신)", unsupported: "사용량 API 없음" } as const;
283
-
284
307
  export function formatUsageReport(rows: readonly UsageRow[], timeZone?: string): string {
285
- if (rows.length === 0) return "계정 사용량\n로그인한 모델 계정이 없습니다.";
286
- const lines = ["계정 사용량"];
308
+ if (rows.length === 0) return `${say(USAGE_TEXT.title)}\n${say(USAGE_TEXT.noAccounts)}`;
309
+ const lines = [say(USAGE_TEXT.title)];
287
310
  for (const row of rows) {
288
- const name = `· ${row.slot} (${row.provider})${row.pinned ? " [고정]" : ""}`;
311
+ const name = say(USAGE_TEXT.account, { slot: row.slot, provider: row.provider, pinned: row.pinned ? say(USAGE_TEXT.pinned) : "" });
289
312
  let body: string;
290
313
  if (row.status === "ok") {
291
- body = row.windows.map((window) => `${window.label} 남음 ${window.remaining}%${window.resetsAt === null ? "" : ` (초기화 ${stamp(window.resetsAt, timeZone)})`}`).join(" · ");
292
- } else if (row.status === "failed") body = `조회 실패 (${row.detail ?? "알 수 없음"})`;
293
- else body = STATUS_TEXT[row.status];
294
- const recent = row.lastUsedAt === undefined ? "" : ` · 최근 사용 ${stamp(row.lastUsedAt, timeZone)}`;
314
+ body = row.windows.map((window) => `${say(USAGE_TEXT.remaining, { label: window.label === "기간" ? say(USAGE_TEXT.period) : window.label, percent: window.remaining })}${window.resetsAt === null ? "" : say(USAGE_TEXT.reset, { time: stamp(window.resetsAt, timeZone) })}`).join(" · ");
315
+ } else if (row.status === "failed") body = say(USAGE_TEXT.failed, { detail: row.detail ?? say(USAGE_TEXT.unknown) });
316
+ else body = say(USAGE_TEXT[row.status]);
317
+ const recent = row.lastUsedAt === undefined ? "" : say(USAGE_TEXT.lastUsed, { time: stamp(row.lastUsedAt, timeZone) });
295
318
  lines.push(`${name}: ${body}${recent}`);
296
319
  }
297
320
  return lines.join("\n");
@@ -318,10 +341,10 @@ export function createUsageReport(deps: UsageDeps & {
318
341
  timeZone?: string;
319
342
  }): () => Promise<string> {
320
343
  return async () => {
321
- if (deps.inContainer) return `계정 사용량\n${USAGE_UNAVAILABLE}`;
344
+ if (deps.inContainer) return `${say(USAGE_TEXT.title)}\n${say(USAGE_UNAVAILABLE)}`;
322
345
  const auth = readJson(join(deps.dir, "auth.json"));
323
- if ("missing" in auth) return "계정 사용량\n로그인한 모델 계정이 없습니다 (에이전트 폴더에 auth.json 없음).";
324
- if ("broken" in auth) return "계정 사용량\nauth.json을 읽지 못했습니다 (JSON 형식 오류).";
346
+ if ("missing" in auth) return `${say(USAGE_TEXT.title)}\n${say(USAGE_TEXT.emptyAuth)}`;
347
+ if ("broken" in auth) return `${say(USAGE_TEXT.title)}\n${say(USAGE_TEXT.brokenAuth)}`;
325
348
  const pool = readJson(join(deps.dir, "credential-pool-state.json"));
326
349
  const rows = await collectUsage(auth.value, "value" in pool ? pool.value : null, (deps.now ?? Date.now)(), deps);
327
350
  return formatUsageReport(rows, deps.timeZone);
package/src/version.ts CHANGED
@@ -1,9 +1,32 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { CORE_NAME, ENGINE_PACKAGE, PLUGIN, OMO_REGISTRY_URL, readEnginePin, formatEnginePin, type EnginePin } from "./engine/index.ts";
3
+ import { say, type Text } from "./server-text.ts";
3
4
  export { OMO_REGISTRY_URL } from "./engine/index.ts";
4
5
 
5
6
  /** `/version`: the gateway answers it with a notice and never forwards it to omo. */
6
- export const VERSION_COMMAND = { name: "version", description: "게이트웨이·엔진 버전 보기 (게이트웨이가 바로 답함)", source: "extension" };
7
+ const VERSION_DESCRIPTION: Text = { en: "View gateway and engine versions (answered by the gateway)", ko: "게이트웨이·엔진 버전 보기 (게이트웨이가 바로 답함)" };
8
+ export const VERSION_COMMAND = { name: "version", get description() { return say(VERSION_DESCRIPTION); }, source: "extension" };
9
+
10
+ const VERSION_TEXT = {
11
+ title: { en: "Version information", ko: "버전 정보" },
12
+ unknown: { en: "Unknown", ko: "알 수 없음" },
13
+ build: { en: "Build commit {value}", ko: "빌드 커밋 {value}" },
14
+ buildUnknown: { en: "Build commit unknown", ko: "빌드 커밋 알 수 없음" },
15
+ gateway: { en: "· Gateway: {version} ({build})", ko: "· 게이트웨이: {version} ({build})" },
16
+ omoDisconnected: { en: "· Engine: check failed (engine connection status {status}){pin}", ko: "· 엔진: 확인 실패 (엔진 연결 상태 {status}){pin}" },
17
+ omoReady: { en: "· Engine: {install}{engine}{pin}", ko: "· 엔진: {install}{engine}{pin}" },
18
+ installUnknown: { en: "Install path unavailable", ko: "설치 경로 확인 실패" },
19
+ engineVersion: { en: " ({name} {version})", ko: " ({name} {version})" },
20
+ pinned: { en: " · Repository-pinned engine {version}", ko: " · 저장소 고정 엔진 {version}" },
21
+ pinUnavailable: { en: " · Failed to read repository-pinned version", ko: " · 저장소 고정 버전 읽기 실패" },
22
+ latest: { en: "· Latest engine: {version} (npm {package})", ko: "· 최신 엔진: {version} (npm {package})" },
23
+ update: { en: "Update available: {current} → {latest} (will not update automatically)", ko: "업데이트 가능: {current} → {latest} (자동으로 업데이트하지 않습니다)" },
24
+ latestFailed: { en: "· Latest engine: check failed ({detail})", ko: "· 최신 엔진: 확인 실패 ({detail})" },
25
+ registryHttp: { en: "npm registry HTTP {status}", ko: "npm 레지스트리 HTTP {status}" },
26
+ registryMissing: { en: "Version missing from npm registry response", ko: "npm 레지스트리 응답에 버전이 없음" },
27
+ timeout: { en: "Timed out ({seconds} seconds)", ko: "시간 초과 ({seconds}초)" },
28
+ networkFailure: { en: "Registry request failed", ko: "레지스트리 요청 실패" },
29
+ } satisfies Record<string, Text>;
7
30
 
8
31
  export function isVersionCommand(text: string): boolean {
9
32
  return /^\/version$/i.test(text.trim());
@@ -75,16 +98,16 @@ export class LatestOmoLookup {
75
98
  private async lookup(): Promise<LatestResult> {
76
99
  try {
77
100
  const res = await this.fetcher(this.url, { signal: AbortSignal.timeout(this.timeoutMs), headers: { accept: "application/json" } });
78
- if (!res.ok) return { error: `npm 레지스트리 HTTP ${res.status}` };
101
+ if (!res.ok) return { error: say(VERSION_TEXT.registryHttp, { status: res.status }) };
79
102
  const body = (await res.json()) as { version?: unknown };
80
- if (typeof body.version !== "string" || !semver(body.version)) return { error: "npm 레지스트리 응답에 버전이 없음" };
103
+ if (typeof body.version !== "string" || !semver(body.version)) return { error: say(VERSION_TEXT.registryMissing) };
81
104
  this.cached = { version: body.version, at: this.now() };
82
105
  return { version: body.version };
83
106
  } catch (error) {
84
107
  if (error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError")) {
85
- return { error: `시간 초과 (${this.timeoutMs / 1000}초)` };
108
+ return { error: say(VERSION_TEXT.timeout, { seconds: this.timeoutMs / 1000 }) };
86
109
  }
87
- return { error: error instanceof Error ? error.message : String(error) };
110
+ return { error: say(VERSION_TEXT.networkFailure) };
88
111
  }
89
112
  }
90
113
  }
@@ -100,26 +123,26 @@ export interface VersionFacts {
100
123
  }
101
124
 
102
125
  export function formatVersionReport(facts: VersionFacts): string {
103
- const lines = ["버전 정보"];
104
- const build = facts.gateway.build ? `빌드 커밋 ${facts.gateway.build}` : "빌드 커밋 알 수 없음";
105
- lines.push(`· 게이트웨이: ${facts.gateway.version ?? "알 수 없음"} (${build})`);
126
+ const lines = [say(VERSION_TEXT.title)];
127
+ const build = facts.gateway.build ? say(VERSION_TEXT.build, { value: facts.gateway.build }) : say(VERSION_TEXT.buildUnknown);
128
+ lines.push(say(VERSION_TEXT.gateway, { version: facts.gateway.version ?? say(VERSION_TEXT.unknown), build }));
106
129
  const pin = facts.pin?.omo
107
- ? ` · 저장소 고정 엔진 ${formatEnginePin(facts.pin)}`
108
- : " · 저장소 고정 버전 읽기 실패";
130
+ ? say(VERSION_TEXT.pinned, { version: formatEnginePin(facts.pin) })
131
+ : say(VERSION_TEXT.pinUnavailable);
109
132
  let running: string | null = null;
110
- if (!facts.omo) lines.push(`· 엔진: 확인 실패 (엔진 연결 상태 ${facts.omoStatus})${pin}`);
133
+ if (!facts.omo) lines.push(say(VERSION_TEXT.omoDisconnected, { status: facts.omoStatus, pin }));
111
134
  else {
112
135
  running = facts.omo.install;
113
- const engine = facts.omo.engine ? ` (${CORE_NAME} ${facts.omo.engine})` : "";
114
- lines.push(`· 엔진: ${facts.omo.install ?? "설치 경로 확인 실패"}${engine}${pin}`);
136
+ const engine = facts.omo.engine ? say(VERSION_TEXT.engineVersion, { name: CORE_NAME, version: facts.omo.engine }) : "";
137
+ lines.push(say(VERSION_TEXT.omoReady, { install: facts.omo.install ?? say(VERSION_TEXT.installUnknown), engine, pin }));
115
138
  }
116
139
  if ("version" in facts.latest) {
117
- lines.push(`· 최신 엔진: ${facts.latest.version} (npm ${ENGINE_PACKAGE})`);
140
+ lines.push(say(VERSION_TEXT.latest, { version: facts.latest.version, package: ENGINE_PACKAGE }));
118
141
  const current = running ?? facts.pin?.omo ?? null;
119
142
  if (current && isNewer(facts.latest.version, current)) {
120
- lines.push(`업데이트 가능: ${current} → ${facts.latest.version} (자동으로 업데이트하지 않습니다)`);
143
+ lines.push(say(VERSION_TEXT.update, { current, latest: facts.latest.version }));
121
144
  }
122
- } else lines.push(`· 최신 엔진: 확인 실패 (${facts.latest.error})`);
145
+ } else lines.push(say(VERSION_TEXT.latestFailed, { detail: facts.latest.error }));
123
146
  return lines.join("\n");
124
147
  }
125
148
 
@@ -1,5 +1,6 @@
1
1
  import { dirname, join } from "node:path";
2
2
  import { type AgentSpec, agentLabel, agentState, bootstrap, type LaunchdContext, type LaunchdDeps, ownership, plistPath, plistXml } from "../cli/launchd.ts";
3
+ import { say, type Text } from "../server-text.ts";
3
4
  import { uvEnvironment, VOICE_MODELS, type VoiceLayout } from "./install.ts";
4
5
 
5
6
  /**
@@ -11,6 +12,10 @@ import { uvEnvironment, VOICE_MODELS, type VoiceLayout } from "./install.ts";
11
12
  export const VOICE_SERVICES = ["stt", "tts"] as const;
12
13
  export type VoiceService = (typeof VOICE_SERVICES)[number];
13
14
 
15
+ const AGENT_FOREIGN: Text = { en: "{label}: this is not a plist of this package, so it is left untouched: {path}", ko: "{label}: 이 패키지의 plist가 아니라 건드리지 않습니다: {path}" };
16
+ const AGENT_STOP_FAILED: Text = { en: "{label} could not be stopped: {error}", ko: "{label} 중지 실패: {error}" };
17
+ const AGENT_REGISTER_FAILED: Text = { en: "{label} could not be registered: {error}", ko: "{label} 등록 실패: {error}" };
18
+
14
19
  export function voiceAgentSpec(ctx: LaunchdContext, layout: VoiceLayout, name: VoiceService): AgentSpec {
15
20
  const server = name === "stt" ? layout.sttServer : layout.ttsServer;
16
21
  const env: Record<string, string> = name === "stt"
@@ -32,14 +37,14 @@ export async function applyVoiceAgents(deps: LaunchdDeps, ctx: LaunchdContext, l
32
37
  const spec = voiceAgentSpec(ctx, layout, name);
33
38
  const path = plistPath(ctx.userHome, spec.label);
34
39
  if (ownership(deps.readAgent(path), ctx, name) === "foreign") {
35
- errors.push(`${spec.label}: 이 패키지의 plist가 아니라 건드리지 않습니다: ${path}`);
40
+ errors.push(say(AGENT_FOREIGN, { label: spec.label, path }));
36
41
  continue;
37
42
  }
38
43
  const state = agentState(deps, ctx, spec.label);
39
44
  if (!enabled) {
40
45
  if (state.loaded) {
41
46
  const out = deps.launchctl(["bootout", `gui/${ctx.uid}/${spec.label}`]);
42
- if (out.code !== 0) errors.push(`${spec.label} 중지 실패: ${out.stderr.trim()}`);
47
+ if (out.code !== 0) errors.push(say(AGENT_STOP_FAILED, { label: spec.label, error: out.stderr.trim() }));
43
48
  }
44
49
  if (deps.readText(path) !== null) deps.remove(path);
45
50
  continue;
@@ -54,7 +59,7 @@ export async function applyVoiceAgents(deps: LaunchdDeps, ctx: LaunchdContext, l
54
59
  }
55
60
  deps.mkdir(ctx.paths.logs);
56
61
  const booted = await bootstrap(deps, ctx, path);
57
- if (booted.code !== 0) errors.push(`${spec.label} 등록 실패: ${booted.stderr.trim()}`);
62
+ if (booted.code !== 0) errors.push(say(AGENT_REGISTER_FAILED, { label: spec.label, error: booted.stderr.trim() }));
58
63
  }
59
64
  return errors;
60
65
  }
@@ -2,6 +2,7 @@ import { appendFileSync, chmodSync, cpSync, existsSync, mkdirSync, readFileSync,
2
2
  import { dirname, join } from "node:path";
3
3
  import { SYSTEM_PATH } from "../cli/launchd.ts";
4
4
  import { type Envelope, PROTOCOL_VERSION } from "../protocol.ts";
5
+ import { say, type Text } from "../server-text.ts";
5
6
 
6
7
  /**
7
8
  * Voice on demand for an npm install (plan Todo 26). The package carries only the two Python server sources; uv, the
@@ -15,13 +16,23 @@ export type InstallStep = (typeof INSTALL_STEPS)[number];
15
16
  /** Shown before the install: whisper 3.0 GB + Qwen3-TTS 2.9 GB (measured on the dev Mac) + the Python environments. */
16
17
  export const ESTIMATED_GB = 8;
17
18
  export const UV_URL_PREFIX = "https://github.com/astral-sh/uv/releases/download/";
18
- export const STEP_MESSAGES: Readonly<Record<InstallStep, string>> = {
19
- uv: "uv 내려받기와 무결성 확인",
20
- servers: "음성 서버 파일 복사",
21
- deps: "Python 의존성 설치",
22
- models: "음성 모델 내려받기",
23
- launchd: "음성 서버 등록",
19
+ export const STEP_MESSAGES: Readonly<Record<InstallStep, Text>> = {
20
+ uv: { en: "Downloading uv and checking its integrity", ko: "uv 내려받기와 무결성 확인" },
21
+ servers: { en: "Copying the voice server files", ko: "음성 서버 파일 복사" },
22
+ deps: { en: "Installing Python dependencies", ko: "Python 의존성 설치" },
23
+ models: { en: "Downloading the voice models", ko: "음성 모델 내려받기" },
24
+ launchd: { en: "Registering the voice servers", ko: "음성 서버 등록" },
24
25
  };
26
+ const UV_LOCK_INVALID: Text = { en: "host/voice/uv.lock.json has an invalid format", ko: "host/voice/uv.lock.json 형식이 맞지 않습니다" };
27
+ const UV_SHA256_MISMATCH: Text = { en: "The sha256 of the uv {version} download differs from the pinned value. The install was stopped", ko: "uv {version} 내려받기의 sha256이 고정 값과 다릅니다. 설치를 중단했습니다" };
28
+ const UV_ARCHIVE_NO_UV: Text = { en: "The uv executable is missing from the uv archive", ko: "uv 압축 파일 안에 uv 실행 파일이 없습니다" };
29
+ const DEPS_FAILED: Text = { en: "Installing the Python dependencies of the {name} server failed (exit code {code})", ko: "{name} 서버의 Python 의존성 설치가 실패했습니다 (종료 코드 {code})" };
30
+ const MODEL_STARTED: Text = { en: "Downloading model: {model}", ko: "모델 받는 중: {model}" };
31
+ const MODEL_DONE: Text = { en: "Downloaded model: {model}", ko: "모델 받음: {model}" };
32
+ const MODELS_FAILED: Text = { en: "Downloading the voice models failed (exit code {code})", ko: "음성 모델 내려받기가 실패했습니다 (종료 코드 {code})" };
33
+ const INSTALL_DONE: Text = { en: "Voice install finished", ko: "음성 설치 완료" };
34
+ const DOWNLOAD_FAILED: Text = { en: "Download failed: HTTP {status} {url}", ko: "내려받기 실패: HTTP {status} {url}" };
35
+ const UNPACK_FAILED: Text = { en: "Could not unpack the uv archive (exit code {code})", ko: "uv 압축 파일을 풀지 못했습니다 (종료 코드 {code})" };
25
36
 
26
37
  export interface UvLock {
27
38
  readonly version: string;
@@ -105,10 +116,10 @@ export function parseUvLock(text: string): UvLock {
105
116
  const lock = JSON.parse(text) as Partial<Record<keyof UvLock, unknown>>;
106
117
  const { version, asset, url, sha256 } = lock;
107
118
  if (typeof version !== "string" || typeof asset !== "string" || typeof url !== "string" || typeof sha256 !== "string") {
108
- throw new VoiceInstallError("uv_lock_invalid", "host/voice/uv.lock.json 형식이 맞지 않습니다");
119
+ throw new VoiceInstallError("uv_lock_invalid", say(UV_LOCK_INVALID));
109
120
  }
110
121
  if (!/^[0-9a-f]{64}$/.test(sha256) || url !== `${UV_URL_PREFIX}${version}/${asset}`) {
111
- throw new VoiceInstallError("uv_lock_invalid", "host/voice/uv.lock.json 형식이 맞지 않습니다");
122
+ throw new VoiceInstallError("uv_lock_invalid", say(UV_LOCK_INVALID));
112
123
  }
113
124
  return { version, asset, url, sha256 };
114
125
  }
@@ -154,7 +165,7 @@ async function installUv(ctx: InstallContext, deps: InstallDeps): Promise<void>
154
165
  const bytes = await deps.download(lock.url);
155
166
  // Checked before anything is written: a wrong archive leaves no voice folder behind.
156
167
  if (sha256Hex(bytes) !== lock.sha256) {
157
- throw new VoiceInstallError("uv_sha256_mismatch", `uv ${lock.version} 내려받기의 sha256이 고정 값과 다릅니다. 설치를 중단했습니다`);
168
+ throw new VoiceInstallError("uv_sha256_mismatch", say(UV_SHA256_MISMATCH, { version: lock.version }));
158
169
  }
159
170
  rmSync(layout.download, { recursive: true, force: true });
160
171
  mkdirSync(layout.download, { recursive: true });
@@ -162,7 +173,7 @@ async function installUv(ctx: InstallContext, deps: InstallDeps): Promise<void>
162
173
  writeFileSync(archive, bytes);
163
174
  await deps.extract(archive, layout.download);
164
175
  const unpacked = join(layout.download, lock.asset.replace(/\.tar\.gz$/, ""), "uv");
165
- if (!existsSync(unpacked)) throw new VoiceInstallError("uv_archive_invalid", `uv 압축 파일 안에 uv 실행 파일이 없습니다`);
176
+ if (!existsSync(unpacked)) throw new VoiceInstallError("uv_archive_invalid", say(UV_ARCHIVE_NO_UV));
166
177
  mkdirSync(layout.bin, { recursive: true });
167
178
  renameSync(unpacked, layout.uv);
168
179
  chmodSync(layout.uv, 0o755);
@@ -180,7 +191,7 @@ async function installDeps(ctx: InstallContext, deps: InstallDeps): Promise<void
180
191
  const { layout } = ctx;
181
192
  for (const [name, server] of [["STT", layout.sttServer], ["TTS", layout.ttsServer]] as const) {
182
193
  const code = await deps.run([layout.uv, "sync", "--script", server], uvEnvironment(layout), () => {});
183
- if (code !== 0) throw new VoiceInstallError("deps_failed", `${name} 서버의 Python 의존성 설치가 실패했습니다 (종료 코드 ${code})`);
194
+ if (code !== 0) throw new VoiceInstallError("deps_failed", say(DEPS_FAILED, { name, code }));
184
195
  }
185
196
  }
186
197
 
@@ -194,8 +205,8 @@ export function modelLine(line: string): string | null {
194
205
  }
195
206
  const { model, state } = (parsed ?? {}) as { model?: unknown; state?: unknown };
196
207
  if (typeof model !== "string") return null;
197
- if (state === "start") return `모델 받는 중: ${model}`;
198
- if (state === "done") return `모델 받음: ${model}`;
208
+ if (state === "start") return say(MODEL_STARTED, { model });
209
+ if (state === "done") return say(MODEL_DONE, { model });
199
210
  return null;
200
211
  }
201
212
 
@@ -206,7 +217,7 @@ async function fetchModels(ctx: InstallContext, deps: InstallDeps, index: number
206
217
  if (message !== null) deps.progress({ state: "running", step: "models", index, total: INSTALL_STEPS.length, message });
207
218
  };
208
219
  const code = await deps.run([layout.uv, "run", "--script", layout.fetchModels, layout.models, VOICE_MODELS.stt, VOICE_MODELS.tts], uvEnvironment(layout), report);
209
- if (code !== 0) throw new VoiceInstallError("models_failed", `음성 모델 내려받기가 실패했습니다 (종료 코드 ${code})`);
220
+ if (code !== 0) throw new VoiceInstallError("models_failed", say(MODELS_FAILED, { code }));
210
221
  }
211
222
 
212
223
  async function registerAgents(deps: InstallDeps): Promise<void> {
@@ -225,7 +236,7 @@ export async function installVoice(ctx: InstallContext, deps: InstallDeps): Prom
225
236
  for (let i = 0; i < total; i++) {
226
237
  const step = INSTALL_STEPS[i]!;
227
238
  if (done.includes(step) && (step !== "uv" || existsSync(layout.uv))) continue;
228
- deps.progress({ state: "running", step, index: i + 1, total, message: STEP_MESSAGES[step] });
239
+ deps.progress({ state: "running", step, index: i + 1, total, message: say(STEP_MESSAGES[step]) });
229
240
  if (step === "uv") await installUv(ctx, deps);
230
241
  else if (step === "servers") copyServers(ctx);
231
242
  else if (step === "deps") await installDeps(ctx, deps);
@@ -235,7 +246,7 @@ export async function installVoice(ctx: InstallContext, deps: InstallDeps): Prom
235
246
  writeFileSync(layout.state, `${JSON.stringify({ done })}\n`);
236
247
  }
237
248
  writeFileSync(layout.installed, `${JSON.stringify({ uv: ctx.lock.version, models: VOICE_MODELS, installedAt: deps.now().toISOString() }, null, 2)}\n`);
238
- deps.progress({ state: "done", step: null, index: total, total, message: "음성 설치 완료" });
249
+ deps.progress({ state: "done", step: null, index: total, total, message: say(INSTALL_DONE) });
239
250
  }
240
251
 
241
252
  export type VoiceInstallProgress = Envelope<"voice.install.progress", InstallProgress>;
@@ -270,12 +281,12 @@ export function systemInstallDeps(log: string, rest: Pick<InstallDeps, "progress
270
281
  ...rest,
271
282
  download: async (url) => {
272
283
  const response = await fetch(url);
273
- if (!response.ok) throw new VoiceInstallError("download_failed", `내려받기 실패: HTTP ${response.status} ${url}`);
284
+ if (!response.ok) throw new VoiceInstallError("download_failed", say(DOWNLOAD_FAILED, { status: response.status, url }));
274
285
  return new Uint8Array(await response.arrayBuffer());
275
286
  },
276
287
  extract: async (archive, dest) => {
277
288
  const code = await Bun.spawn(["/usr/bin/tar", "-xzf", archive, "-C", dest], { stdout: "ignore", stderr: "ignore" }).exited;
278
- if (code !== 0) throw new VoiceInstallError("uv_archive_invalid", `uv 압축 파일을 풀지 못했습니다 (종료 코드 ${code})`);
289
+ if (code !== 0) throw new VoiceInstallError("uv_archive_invalid", say(UNPACK_FAILED, { code }));
279
290
  },
280
291
  run: async (args, env, onLine) => {
281
292
  mkdirSync(dirname(log), { recursive: true });