zigmini 0.9.0-beta.2 → 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 (145) hide show
  1. package/README.ko.md +35 -5
  2. package/README.md +35 -5
  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/model-control.ts +15 -3
  13. package/host/omo-bridge/task-control.ts +26 -16
  14. package/host/omo-check/patches/omo-5.1.48-compaction-recovery.patch +912 -0
  15. package/host/omo-check/patches/omo-5.1.48-gateway-port.manifest.json +12 -11
  16. package/host/omo-check/patches/omo-5.1.51-compaction-long-context.patch +92 -0
  17. package/host/omo-check/patches/omo-5.1.51-compaction-port.patch +2635 -0
  18. package/host/omo-check/patches/omo-5.1.51-compaction-recovery.patch +911 -0
  19. package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +363 -0
  20. package/host/omo-check/patches/omo-5.1.51-provider-failover.patch +50 -0
  21. package/host/omo-check/patches/omo-5.1.51-provider-port.patch +441 -0
  22. package/host/omo-check/patches/omo-5.1.51-rpc-port.patch +799 -0
  23. package/host/omo-check/patches/omo-5.1.51-runtime-compat.patch +31 -0
  24. package/package.json +3 -2
  25. package/src/agent/api.ts +215 -0
  26. package/src/agent/definition.ts +177 -0
  27. package/src/agent/fields.ts +42 -0
  28. package/src/agent/files.ts +192 -0
  29. package/src/agent/legacy-files.ts +46 -0
  30. package/src/agent/list.ts +21 -0
  31. package/src/agent-rules.ts +14 -5
  32. package/src/approvals.ts +6 -1
  33. package/src/cli/main.ts +33 -0
  34. package/src/cli/messages.ts +10 -2
  35. package/src/cli/rotate-main.ts +2 -1
  36. package/src/cli/scan.ts +414 -0
  37. package/src/cli/security-import.ts +155 -0
  38. package/src/compact.ts +22 -0
  39. package/src/decisions.ts +6 -4
  40. package/src/engine/compat.json +3 -3
  41. package/src/engine/index.ts +1 -0
  42. package/src/engine/schema-guard.ts +12 -4
  43. package/src/engine/sdk.ts +8 -0
  44. package/src/formal-verification.ts +366 -0
  45. package/src/gc.ts +37 -6
  46. package/src/hub.ts +525 -85
  47. package/src/main.ts +95 -9
  48. package/src/maintenance.ts +1 -1
  49. package/src/manual.ts +9 -2
  50. package/src/omo/client.ts +2 -0
  51. package/src/profile-prompt.ts +73 -0
  52. package/src/profile.ts +33 -10
  53. package/src/protocol.ts +11 -2
  54. package/src/question-notifications.ts +4 -1
  55. package/src/reply-notifications.ts +7 -1
  56. package/src/repo-security-setting.ts +33 -0
  57. package/src/runtime/paths.ts +16 -0
  58. package/src/security/baseline.ts +170 -0
  59. package/src/security/dex-strings.ts +242 -0
  60. package/src/security/repo-scan.ts +369 -0
  61. package/src/security/revision-probe.ts +144 -0
  62. package/src/security/terms.ts +97 -0
  63. package/src/security-allow.ts +1 -0
  64. package/src/security-baseline.ts +113 -0
  65. package/src/security-check.ts +91 -16
  66. package/src/server-text.ts +38 -0
  67. package/src/store.ts +142 -16
  68. package/src/transcript.ts +7 -1
  69. package/src/ui-language.ts +33 -0
  70. package/src/update/check.ts +7 -3
  71. package/src/update/handoff-system.ts +24 -16
  72. package/src/update/handoff.ts +23 -12
  73. package/src/update/helper.ts +72 -36
  74. package/src/update/request.ts +46 -30
  75. package/src/update/state.ts +21 -11
  76. package/src/update/strategy.ts +12 -5
  77. package/src/update/target.ts +32 -15
  78. package/src/usage.ts +43 -20
  79. package/src/version.ts +39 -16
  80. package/src/voice/agents.ts +8 -3
  81. package/src/voice/install.ts +29 -18
  82. package/src/work-watch.ts +40 -18
  83. package/web/agent-icon.js +15 -9
  84. package/web/agents.d.ts +201 -0
  85. package/web/agents.js +813 -0
  86. package/web/app-download.js +32 -19
  87. package/web/app.js +1407 -278
  88. package/web/autocopy.js +6 -1
  89. package/web/boot.js +27 -8
  90. package/web/commands.js +20 -11
  91. package/web/decisions.js +70 -49
  92. package/web/devlog.js +25 -19
  93. package/web/diagram.js +8 -3
  94. package/web/enroll.js +43 -22
  95. package/web/gc.js +32 -19
  96. package/web/i18n/agent-icon.js +24 -0
  97. package/web/i18n/agents.js +48 -0
  98. package/web/i18n/app-download.js +26 -0
  99. package/web/i18n/autocopy.js +5 -0
  100. package/web/i18n/boot.js +24 -0
  101. package/web/i18n/commands.js +34 -0
  102. package/web/i18n/decisions.js +24 -0
  103. package/web/i18n/devlog.js +60 -0
  104. package/web/i18n/diagram.js +12 -0
  105. package/web/i18n/enroll.js +22 -0
  106. package/web/i18n/gc.js +38 -0
  107. package/web/i18n/lastsent.js +10 -0
  108. package/web/i18n/main.d.ts +4 -0
  109. package/web/i18n/main.js +527 -0
  110. package/web/i18n/manual.d.ts +4 -0
  111. package/web/i18n/manual.js +14 -0
  112. package/web/i18n/memo.js +30 -0
  113. package/web/i18n/notifications.js +16 -0
  114. package/web/i18n/profile.js +13 -0
  115. package/web/i18n/reference.js +26 -0
  116. package/web/i18n/security-inbox.js +40 -0
  117. package/web/i18n/slash.js +38 -0
  118. package/web/i18n/turn-stats.js +5 -0
  119. package/web/i18n/update.js +42 -0
  120. package/web/i18n/voice.js +28 -0
  121. package/web/i18n/wake.js +16 -0
  122. package/web/i18n/workid.js +10 -0
  123. package/web/i18n/workstatus.js +20 -0
  124. package/web/i18n.d.ts +20 -0
  125. package/web/i18n.js +63 -0
  126. package/web/index.html +156 -96
  127. package/web/lastsent.js +9 -4
  128. package/web/manual.d.ts +1 -1
  129. package/web/manual.js +11 -5
  130. package/web/memo.js +18 -13
  131. package/web/notifications.js +12 -7
  132. package/web/profile.js +42 -17
  133. package/web/reference.js +20 -15
  134. package/web/security-inbox.d.ts +18 -1
  135. package/web/security-inbox.js +173 -4
  136. package/web/slash.js +23 -13
  137. package/web/style.css +132 -3
  138. package/web/tooltip.js +5 -2
  139. package/web/turn-stats.js +6 -2
  140. package/web/update.js +31 -23
  141. package/web/voice.js +18 -8
  142. package/web/wake.d.ts +4 -0
  143. package/web/wake.js +7 -3
  144. package/web/workid.js +12 -2
  145. package/web/workstatus.js +36 -32
package/web/autocopy.js CHANGED
@@ -1,3 +1,8 @@
1
+ import strings from "./i18n/autocopy.js";
2
+ import { registerStrings, t } from "./i18n.js";
3
+
4
+ registerStrings(strings);
5
+
1
6
  const EDITABLE = "input, textarea, select, [contenteditable]:not([contenteditable='false'])";
2
7
 
3
8
  function elementOf(node) {
@@ -50,7 +55,7 @@ export function setupAutoCopy(messages) {
50
55
  document.body.append(toast);
51
56
  let hideTimer = 0;
52
57
  const show = (ok) => {
53
- toast.textContent = ok ? "복사했습니다" : "복사하지 못했습니다";
58
+ toast.textContent = t(ok ? "autocopy.copied" : "autocopy.failed");
54
59
  toast.classList.toggle("error", !ok);
55
60
  toast.hidden = false;
56
61
  clearTimeout(hideTimer);
package/web/boot.js CHANGED
@@ -1,5 +1,22 @@
1
+ import dict from "./i18n/boot.js";
2
+ import { applyI18n, isUiLanguage, registerStrings, setLanguage, t } from "./i18n.js";
3
+
4
+ registerStrings(dict);
5
+
1
6
  const POLL_MS = 2000;
2
7
 
8
+ /** This device is not trusted yet, so the gateway may refuse the setting; the browser language is the fallback. */
9
+ async function pickLanguage() {
10
+ try {
11
+ const res = await fetch("/api/settings/language", { cache: "no-store" });
12
+ const body = res.ok ? await res.json() : null;
13
+ if (isUiLanguage(body?.language)) return setLanguage(body.language);
14
+ } catch {
15
+ // Fall through to the browser language.
16
+ }
17
+ setLanguage(String(navigator.language).toLowerCase().startsWith("ko") ? "ko" : "en");
18
+ }
19
+
3
20
  async function status() {
4
21
  const res = await fetch("/api/device/status", { cache: "no-store" });
5
22
  return res.json();
@@ -11,12 +28,13 @@ function screen() {
11
28
  box.innerHTML = `
12
29
  <div class="pairing-card">
13
30
  <div class="pairing-logo">Z</div>
14
- <h1>이 기기를 등록해야 합니다</h1>
31
+ <h1 data-i18n="boot.title"></h1>
15
32
  <p class="pairing-msg"></p>
16
33
  <div class="pairing-code" hidden></div>
17
- <button type="button" class="send pairing-btn" hidden>등록 요청</button>
34
+ <button type="button" class="send pairing-btn" data-i18n="boot.request" hidden></button>
18
35
  </div>`;
19
36
  document.body.replaceChildren(box);
37
+ applyI18n(box);
20
38
  return {
21
39
  msg: box.querySelector(".pairing-msg"),
22
40
  code: box.querySelector(".pairing-code"),
@@ -37,17 +55,17 @@ async function pair(first) {
37
55
  const deadline = Date.now() + s.msLeft;
38
56
  const tick = () => {
39
57
  const left = Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
40
- ui.msg.textContent = `맥의 웹 지그미니 화면에 뜬 요청을 ${left}초 안에 승인해 주세요. 같은 번호인지 확인하세요. (${s.name})`;
58
+ ui.msg.textContent = t("boot.pending", { seconds: left, name: s.name });
41
59
  };
42
60
  tick();
43
61
  countdown = setInterval(tick, 1000);
44
62
  ui.code.textContent = s.code;
45
63
  ui.code.hidden = false;
46
64
  } else if (s.locked) {
47
- ui.msg.textContent = "등록 요청이 3번 실패해 잠겼습니다. 맥에서 잠금을 풀어야 다시 요청할 수 있습니다.";
65
+ ui.msg.textContent = t("boot.locked");
48
66
  } else {
49
- const head = s.state === "denied" ? "등록이 거절됐거나 30초 안에 승인되지 않았습니다." : "이 폰에서 처음 접속했습니다.";
50
- ui.msg.textContent = `${head} 등록을 요청하면 맥에서 30초 안에 승인해야 합니다. (남은 요청 ${s.attemptsLeft}번)`;
67
+ const head = t(s.state === "denied" ? "boot.denied" : "boot.first");
68
+ ui.msg.textContent = t("boot.hint", { head, left: s.attemptsLeft });
51
69
  ui.button.hidden = false;
52
70
  }
53
71
  };
@@ -56,11 +74,11 @@ async function pair(first) {
56
74
  try {
57
75
  const res = await fetch("/api/device/request", { method: "POST" });
58
76
  const body = await res.json();
59
- if (res.status === 429) throw new Error("다른 등록 요청이 처리 중입니다. 잠시 후 다시 시도하세요.");
77
+ if (res.status === 429) throw new Error(t("boot.busy"));
60
78
  if (!res.ok && res.status !== 423) throw new Error(body.error ?? String(res.status));
61
79
  show(body);
62
80
  } catch (err) {
63
- ui.msg.textContent = `요청하지 못했습니다: ${err instanceof Error ? err.message : err}`;
81
+ ui.msg.textContent = t("boot.failed", { reason: err instanceof Error ? err.message : err });
64
82
  } finally {
65
83
  ui.button.disabled = false;
66
84
  }
@@ -69,6 +87,7 @@ async function pair(first) {
69
87
  setInterval(() => status().then(show).catch(() => {}), POLL_MS);
70
88
  }
71
89
 
90
+ await pickLanguage();
72
91
  const first = await status().catch(() => ({ state: "none" }));
73
92
  if (first.state === "trusted" || first.state === "approved") await import("./app.js");
74
93
  else await pair(first);
package/web/commands.js CHANGED
@@ -1,13 +1,22 @@
1
+ import dict from "./i18n/commands.js";
2
+
3
+ const words = (key) => [...new Set([dict.en[key], dict.ko[key]].flatMap((value) => value.split(",")))];
4
+ const escaped = (list) => list.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
5
+
1
6
  const TARGETS = [
2
- { option: "tts", names: ["/tts", "tts", "음성답변", "음성응답"] },
3
- { option: "wake", names: ["/wake", "wake", "호출어대기", "호출어", "웨이크워드"] },
4
- { option: "clap", names: ["/clap", "clap", "박수감지", "박수"] },
7
+ { option: "tts", names: words("commands.tts.names") },
8
+ { option: "wake", names: words("commands.wake.names") },
9
+ { option: "clap", names: words("commands.clap.names") },
5
10
  ];
6
- const ON = new Set(["on", "켜", "켜줘", "켜기", "켜라", "켜주세요", "활성화", "활성화해", "활성화해줘", "활성화해주세요", "활성화시켜", "활성화시켜줘"]);
7
- const OFF = new Set(["off", "꺼", "꺼줘", "끄기", "꺼라", "꺼주세요", "비활성화", "비활성화해", "비활성화해줘", "비활성화해주세요", "비활성화시켜", "비활성화시켜줘"]);
8
- const STATUS = new Set(["/옵션", "/options", "옵션", "옵션상태"]);
9
- const READ_FULL = new Set(["/read", "전체읽어줘", "전체읽어", "전체다읽어줘", "전부읽어줘", "다읽어줘", "전체읽어주세요", "전체듣기"]);
10
- const CLOSE_EARS = new Set(["귀닫아", "귀닫아줘", "귀좀닫아", "귀좀닫아줘", "귀닫아라"]);
11
+ const ON = new Set(words("commands.on"));
12
+ const OFF = new Set(words("commands.off"));
13
+ const STATUS = new Set(words("commands.status"));
14
+ const READ_FULL = new Set(words("commands.readFull"));
15
+ const CLOSE_EARS = new Set(words("commands.closeEars"));
16
+ const DEVICES = new Set(words("commands.devices"));
17
+ const DEVICE_DELETE = new RegExp(`^(?:${escaped(words("commands.devices.prefix"))})(?:${escaped(words("commands.devices.delete"))})(\\d+)$`);
18
+ const MODE = new RegExp(`^(?:${escaped(words("commands.mode"))})`);
19
+ const PARTICLE = new RegExp(`^(?:${escaped(words("commands.particle"))})`);
11
20
  // Improvement 71: the gateway voice setting (the slash menu offers /voice on and /voice off).
12
21
  const VOICE_NAMES = ["/voice", "voice"];
13
22
 
@@ -16,13 +25,13 @@ export function parseOptionCommand(text) {
16
25
  if (STATUS.has(s)) return { kind: "status" };
17
26
  if (READ_FULL.has(s)) return { kind: "readFull" };
18
27
  if (CLOSE_EARS.has(s)) return { kind: "set", option: "wake", value: false };
19
- if (s === "/기기" || s === "기기목록") return { kind: "devices" };
20
- const del = /^\/기기(?:삭제|지워|지워줘)(\d+)$/.exec(s);
28
+ if (DEVICES.has(s)) return { kind: "devices" };
29
+ const del = DEVICE_DELETE.exec(s);
21
30
  if (del) return { kind: "deviceDelete", index: Number(del[1]) };
22
31
  for (const { option, names } of TARGETS) {
23
32
  const name = names.find((n) => s.startsWith(n));
24
33
  if (!name) continue;
25
- const rest = s.slice(name.length).replace(/^모드/, "").replace(/^[을를]/, "");
34
+ const rest = s.slice(name.length).replace(MODE, "").replace(PARTICLE, "");
26
35
  if (ON.has(rest)) return { kind: "set", option, value: true };
27
36
  if (OFF.has(rest)) return { kind: "set", option, value: false };
28
37
  }
package/web/decisions.js CHANGED
@@ -1,6 +1,10 @@
1
1
  // 확인함(confirmation inbox) state, presentation rules and request builders.
2
2
  // Pure: no DOM, no fetch. Rendering and transport live in app.js.
3
3
  // allow: SIZE_OK — one pure reducer owns the indivisible canonical decision and answer-request state machine.
4
+ import strings from "./i18n/decisions.js";
5
+ import { onLanguageChange, registerStrings, t } from "./i18n.js";
6
+
7
+ registerStrings(strings);
4
8
 
5
9
  const UNRESOLVED = new Set(["pending", "submitted", "needs_review"]);
6
10
  const TERMINAL_WORK = new Set(["completed", "failed", "cancelled"]);
@@ -366,13 +370,17 @@ export function canDismiss(decision) {
366
370
  return isUnresolved(decision) && decision.status !== "submitted";
367
371
  }
368
372
 
369
- export const DISMISS_TEXT = {
370
- hide: { prompt: "확인함에서 지웁니다. 질문과 답변 기록은 남습니다.", confirm: "삭제" },
371
- cancel: {
372
- prompt: "아직 답을 기다리는 질문입니다. 지우면 질문을 낸 작업에 취소로 전달되고, 그 작업은 답 없이 진행하거나 멈출 수 있습니다.",
373
- confirm: "질문 취소하고 삭제",
374
- },
375
- };
373
+ export let DISMISS_TEXT = dismissText();
374
+
375
+ function dismissText() {
376
+ return {
377
+ hide: { prompt: t("decisions.dismiss.hide.prompt"), confirm: t("decisions.dismiss.hide.confirm") },
378
+ cancel: {
379
+ prompt: t("decisions.dismiss.cancel.prompt"),
380
+ confirm: t("decisions.dismiss.cancel.confirm"),
381
+ },
382
+ };
383
+ }
376
384
 
377
385
  function record(value) {
378
386
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -380,7 +388,7 @@ function record(value) {
380
388
 
381
389
  /** One question's line in the inbox: its header, text and the multi-select / optional hints. */
382
390
  export function questionTitle(q) {
383
- return `${q.header ? `[${q.header}] ` : ""}${typeof q.question === "string" ? q.question : ""}${q.multiSelect === true ? " (여러 개 선택 가능)" : ""}${q.required === false ? " (선택)" : ""}`;
391
+ return `${q.header ? `[${q.header}] ` : ""}${typeof q.question === "string" ? q.question : ""}${q.multiSelect === true ? t("decisions.question.multi") : ""}${q.required === false ? t("decisions.question.optional") : ""}`;
384
392
  }
385
393
 
386
394
  /**
@@ -399,7 +407,7 @@ export function originalChoices(decision) {
399
407
  return choices.length ? [{ title: "", options: choices }] : [];
400
408
  }
401
409
  case "confirm":
402
- return [{ title: "", options: [{ label: "예", description: "" }, { label: "아니오", description: "" }] }];
410
+ return [{ title: "", options: [{ label: t("decisions.confirm.yes"), description: "" }, { label: t("decisions.confirm.no"), description: "" }] }];
403
411
  case "question":
404
412
  return (Array.isArray(payload.questions) ? payload.questions : []).filter(record)
405
413
  .map((q) => ({ title: questionTitle(q), options: options(q.options) }));
@@ -477,7 +485,7 @@ export function interpretAnswerResponse(status, body) {
477
485
  if (status === 404) return { kind: "gone", error };
478
486
  if (status === 400) return { kind: "rejected", decision, error };
479
487
  if (status === 422 && error === "security_check_blocked") {
480
- return { kind: "blocked", error, held: record(body.held) ? body.held : null, message: typeof body.message === "string" ? body.message : "보안검사에 걸려 보내지 않고 보류했습니다." };
488
+ return { kind: "blocked", error, held: record(body.held) ? body.held : null, message: typeof body.message === "string" ? body.message : t("decisions.blocked") };
481
489
  }
482
490
  return { kind: "unavailable", status, error };
483
491
  }
@@ -518,39 +526,52 @@ export function speechTextForTurn(turn, lastText, textAt) {
518
526
  return lastText ?? null;
519
527
  }
520
528
 
521
- export const STATUS_LABELS = { pending: "확인 필요", needs_review: "확인 필요", submitted: "전달 중" };
522
- export const BINDING_LABELS = { live: "답 기다리는 중", expired: "기한 만료", closed: "요청 종료", unknown: "상태 불명" };
529
+ export let STATUS_LABELS = statusLabels();
530
+ export let BINDING_LABELS = bindingLabels();
531
+
532
+ function statusLabels() {
533
+ return { pending: t("decisions.status.pending"), needs_review: t("decisions.status.pending"), submitted: t("decisions.status.submitted") };
534
+ }
535
+
536
+ function bindingLabels() {
537
+ return { live: t("decisions.binding.live"), expired: t("decisions.binding.expired"), closed: t("decisions.binding.closed"), unknown: t("decisions.binding.unknown") };
538
+ }
523
539
 
524
540
  /** A closed question that still takes an answer (reported again, or kept open so main does not pause) waits for it. */
525
541
  export function bindingLabel(decision) {
526
- if (decision.bindingState === "closed" && (decision.reason === "report_only" || decision.reason === "main_released")) return "답변 대기";
542
+ if (decision.bindingState === "closed" && (decision.reason === "report_only" || decision.reason === "main_released")) return t("decisions.binding.answer");
527
543
  return BINDING_LABELS[decision.bindingState] ?? decision.bindingState;
528
544
  }
529
- export const WORK_STATUS_LABELS = {
530
- submitted: "접수됨", running: "실행 중", waiting: "대기 중", waiting_input: "답변 기다리는 중",
531
- completed: "완료", failed: "실패", cancelled: "취소됨", unknown: "상태 불명",
532
- };
533
- export const ROUTE_LABELS = { live_response: "실행 중인 요청에 바로 전달", follow_up_command: "후속 지시로 전달" };
545
+ export let WORK_STATUS_LABELS = workStatusLabels();
546
+ export let ROUTE_LABELS = routeLabels();
547
+
548
+ function workStatusLabels() {
549
+ return {
550
+ submitted: t("decisions.work.submitted"), running: t("decisions.work.running"), waiting: t("decisions.work.waiting"), waiting_input: t("decisions.work.waiting_input"),
551
+ completed: t("decisions.work.completed"), failed: t("decisions.work.failed"), cancelled: t("decisions.work.cancelled"), unknown: t("decisions.work.unknown"),
552
+ };
553
+ }
554
+
555
+ function routeLabels() {
556
+ return { live_response: t("decisions.route.live_response"), follow_up_command: t("decisions.route.follow_up_command") };
557
+ }
558
+
559
+ // The exported texts follow the UI language (app.js reads them at render time).
560
+ onLanguageChange(() => {
561
+ DISMISS_TEXT = dismissText();
562
+ STATUS_LABELS = statusLabels();
563
+ BINDING_LABELS = bindingLabels();
564
+ WORK_STATUS_LABELS = workStatusLabels();
565
+ ROUTE_LABELS = routeLabels();
566
+ });
534
567
  const REASON_LABELS = {
535
- receipt_unknown: "전달 확인을 받지 못했습니다",
536
- user_deferred: "나중에 답하기로 미뤘습니다",
537
- source_changed: "질문 내용이 바뀌었습니다",
538
- recheck_incomplete: "재확인 결과가 아직 오지 않았습니다",
539
- engine_wait_closed: "원래 요청이 답 없이 끝났습니다",
540
- engine_wait_cancelled: "원래 요청이 취소됐습니다",
541
- request_missing: "원래 요청을 찾을 수 없습니다",
542
- owner_disconnected: "담당 세션 연결이 끊겼습니다",
543
- binding_unknown: "원래 요청 상태를 알 수 없습니다",
544
- owner_unknown: "현재 담당 세션을 확인할 수 없습니다",
545
- parked: "담당 세션이 잠시 멈춰 있습니다",
546
- command_pending: "후속 지시를 보내는 중입니다",
547
- timeout: "기한이 지났습니다",
548
- report_only: "현재 담당이 다시 낸 질문입니다",
549
- main_released: "답을 기다리는 질문입니다",
550
- instructions_changed: "질문 이후 새 지시가 추가됐습니다",
551
- context_changed: "질문 이후 새 지시가 추가됐습니다",
552
- recheck_requested: "현재 상황 확인을 요청했습니다",
553
- request_permission_requested: "새 승인 요청을 보냈습니다",
568
+ receipt_unknown: "decisions.reason.receipt_unknown", user_deferred: "decisions.reason.user_deferred", source_changed: "decisions.reason.source_changed",
569
+ recheck_incomplete: "decisions.reason.recheck_incomplete", engine_wait_closed: "decisions.reason.engine_wait_closed", engine_wait_cancelled: "decisions.reason.engine_wait_cancelled",
570
+ request_missing: "decisions.reason.request_missing", owner_disconnected: "decisions.reason.owner_disconnected", binding_unknown: "decisions.reason.binding_unknown",
571
+ owner_unknown: "decisions.reason.owner_unknown", parked: "decisions.reason.parked", command_pending: "decisions.reason.command_pending",
572
+ timeout: "decisions.reason.timeout", report_only: "decisions.reason.report_only", main_released: "decisions.reason.main_released",
573
+ instructions_changed: "decisions.reason.instructions_changed", context_changed: "decisions.reason.context_changed",
574
+ recheck_requested: "decisions.reason.recheck_requested", request_permission_requested: "decisions.reason.request_permission_requested",
554
575
  };
555
576
 
556
577
  /** Why the item still waits, in the user's words; unmapped server codes are shown as-is. */
@@ -558,30 +579,30 @@ export function waitReason(decision) {
558
579
  const parts = [];
559
580
  const work = decision.work;
560
581
  if (work && TERMINAL_WORK.has(work.status)) {
561
- parts.push(`작업이 ${WORK_STATUS_LABELS[work.status] ?? work.status} 상태라 더 실행할 수 없습니다. 원문과 이력만 볼 수 있습니다.`);
582
+ parts.push(t("decisions.wait.work_ended", { status: WORK_STATUS_LABELS[work.status] ?? work.status }));
562
583
  return parts.join(" ");
563
584
  }
564
585
  if (decision.status === "submitted") {
565
- if (decision.reason?.endsWith("_requested")) parts.push(`${REASON_LABELS[decision.reason] ?? decision.reason}. 담당의 응답을 기다립니다.`);
586
+ if (decision.reason?.endsWith("_requested")) parts.push(t("decisions.wait.requested", { reason: t(REASON_LABELS[decision.reason] ?? decision.reason) }));
566
587
  else {
567
- parts.push("답변을 전달하는 중입니다.");
568
- if (decision.answerRoute) parts.push(`${ROUTE_LABELS[decision.answerRoute]}.`);
588
+ parts.push(t("decisions.wait.answer_sending"));
589
+ if (decision.answerRoute) parts.push(`${ROUTE_LABELS[decision.answerRoute] ?? decision.answerRoute}.`);
569
590
  }
570
591
  } else if (decision.status === "needs_review") {
571
- parts.push(`${REASON_LABELS[decision.reason ?? ""] ?? decision.reason ?? "다시 확인이 필요합니다"}. 현재 상황을 확인해 주세요.`);
592
+ parts.push(t("decisions.wait.recheck", { reason: t(REASON_LABELS[decision.reason ?? ""] ?? decision.reason ?? "decisions.wait.recheck") }));
572
593
  } else if (decision.bindingState === "live") {
573
- parts.push("지금 답을 기다리고 있습니다.");
594
+ parts.push(t("decisions.wait.live"));
574
595
  } else if (decision.bindingState === "expired") {
575
596
  parts.push(decision.permission
576
- ? "승인 요청 기한이 지났습니다. 이전 승인은 다시 쓰지 않고 현재 실행 내용으로 새 승인 요청을 받아야 합니다."
577
- : "기한은 지났지만 결정은 아직 필요합니다. 답하면 현재 작업 상황에 맞춰 이어서 전달합니다.");
597
+ ? t("decisions.wait.permission_expired")
598
+ : t("decisions.wait.expired"));
578
599
  } else if (decision.bindingState === "closed" && decision.reason === "report_only") {
579
- parts.push("현재 담당이 다시 낸 질문입니다. 답하면 그대로 전달합니다.");
600
+ parts.push(t("decisions.wait.report_only"));
580
601
  } else if (decision.bindingState === "closed" && decision.reason === "main_released") {
581
- parts.push("답을 기다리는 질문입니다. 그동안 대화는 멈추지 않습니다. 답하면 그대로 전달합니다.");
602
+ parts.push(t("decisions.wait.main_released"));
582
603
  } else {
583
- parts.push(`${REASON_LABELS[decision.reason ?? ""] ?? decision.reason ?? "요청 상태를 확인할 수 없습니다"}. 현재 상황을 확인해 주세요.`);
604
+ parts.push(t("decisions.wait.unknown", { reason: t(REASON_LABELS[decision.reason ?? ""] ?? decision.reason ?? "decisions.wait.unknown") }));
584
605
  }
585
- if (work) parts.push(`작업 상태: ${WORK_STATUS_LABELS[work.status] ?? work.status}${work.statusReason ? ` (${work.statusReason})` : ""}.`);
606
+ if (work) parts.push(t("decisions.wait.work_status", { status: WORK_STATUS_LABELS[work.status] ?? work.status, reason: work.statusReason ? ` (${work.statusReason})` : "" }));
586
607
  return parts.join(" ");
587
608
  }
package/web/devlog.js CHANGED
@@ -1,11 +1,16 @@
1
+ import { registerStrings, t } from "./i18n.js";
2
+ import strings from "./i18n/devlog.js";
3
+
4
+ registerStrings(strings);
5
+
1
6
  /**
2
- * The chat line for a main turn end (turn.completed), or null. The 작업 상태 drawing already shows whether Zig is working,
7
+ * The chat line for a main turn end (turn.completed), or null. The work status drawing already shows whether Zig is working,
3
8
  * so a normal end appears only in this event log. An abnormal end ("interrupted") stays in the chat: a work report turn
4
9
  * interrupted on reattach has no other notice.
5
10
  */
6
11
  export function chatTurnEndLine(data) {
7
12
  if (data.status === "completed") return null;
8
- return `응답 완료: ${data.status}${data.error ? ` (${JSON.stringify(data.error)})` : ""}`;
13
+ return t("devlog.turnEnd", { status: data.status, error: data.error ? ` (${JSON.stringify(data.error)})` : "" });
9
14
  }
10
15
 
11
16
  const CATEGORY = {
@@ -60,50 +65,50 @@ function summary(type, d = {}) {
60
65
  return clip(d.title || d.method);
61
66
  case "action.resolved": {
62
67
  const a = d.answer ?? {};
63
- const v = a.kind === "value" ? a.value : a.kind === "confirm" ? (a.confirmed ? "예" : "아니오") : "취소";
68
+ const v = a.kind === "value" ? a.value : a.kind === "confirm" ? (a.confirmed ? t("devlog.yes") : t("devlog.no")) : t("devlog.cancel");
64
69
  return clip(`${v} (${d.by})${a.reason ? ` · ${a.reason}` : ""}`);
65
70
  }
66
71
  case "turn.started":
67
- return "작업 시작";
72
+ return t("devlog.turnStarted");
68
73
  case "turn.completed":
69
- return clip(`응답 완료 · ${d.status}`);
74
+ return clip(t("devlog.turnCompleted", { status: d.status }));
70
75
  case "error":
71
76
  case "notice":
72
77
  return clip(d.message);
73
78
  case "omo.status":
74
- return clip(`엔진 ${d.status}${d.detail ? ` · ${d.detail}` : ""}`);
79
+ return clip(t("devlog.engineStatus", { status: d.status, detail: d.detail ? ` · ${d.detail}` : "" }));
75
80
  case "state.snapshot":
76
- return `대화 ${d.conversations?.length ?? 0} · 승인 대기 ${d.approvals?.length ?? 0} · 엔진 ${d.omo}`;
81
+ return t("devlog.snapshot", { conversations: d.conversations?.length ?? 0, approvals: d.approvals?.length ?? 0, engine: d.omo });
77
82
  case "history":
78
- return `${d.conv} 기록 ${d.events?.length ?? 0}건`;
83
+ return t("devlog.history", { conversation: d.conv, count: d.events?.length ?? 0 });
79
84
  case "conversation.created":
80
- return clip(`새 대화 ${d.title}`);
85
+ return clip(t("devlog.newConversation", { title: d.title }));
81
86
  case "conversation.thread":
82
87
  return clip(d.threadId);
83
88
  case "profile.updated":
84
- return clip(`닉네임 ${d.nickname ?? ""}${d.agentName ? ` · 에이전트 ${d.agentName}` : ""}`);
89
+ return clip(t("devlog.profile", { nickname: d.nickname ?? "", agent: d.agentName ? ` · ${t("devlog.agent")} ${d.agentName}` : "" }));
85
90
  case "voice.updated":
86
- return d.enabled === false ? "음성 꺼짐" : "음성 켜짐";
91
+ return d.enabled === false ? t("devlog.voiceOff") : t("devlog.voiceOn");
87
92
  case "voice.install.progress":
88
- return clip(`음성 설치 ${d.state} ${d.index}/${d.total} ${d.message ?? ""}`);
93
+ return clip(t("devlog.voiceInstall", { state: d.state, index: d.index, total: d.total, message: d.message ?? "" }));
89
94
  case "version.available":
90
- return clip(`새 버전 ${d.latest ?? "?"} (지금 ${d.current ?? "?"})`);
95
+ return clip(t("devlog.versionAvailable", { latest: d.latest ?? "?", current: d.current ?? "?" }));
91
96
  case "update.progress":
92
- return clip(`업데이트 ${d.from ?? "?"} → ${d.to ?? "?"}: ${d.label ?? d.phase ?? ""}`);
97
+ return clip(t("devlog.updateProgress", { from: d.from ?? "?", to: d.to ?? "?", label: d.label ?? d.phase ?? "" }));
93
98
  case "update.result":
94
- return clip(`업데이트 ${d.ok ? "성공" : d.rolledBack ? "실패, 되돌림" : "실패"} ${d.from ?? "?"} → ${d.to ?? "?"}`);
99
+ return clip(t("devlog.updateResult", { result: d.ok ? t("devlog.success") : d.rolledBack ? t("devlog.rollback") : t("devlog.failure"), from: d.from ?? "?", to: d.to ?? "?" }));
95
100
  case "memo.updated":
96
- return `쪽지 ${d.text?.length ?? 0}자${d.width ? ` · ${d.width}×${d.height}` : ""}`;
101
+ return t("devlog.memo", { count: d.text?.length ?? 0, size: d.width ? ` · ${d.width}×${d.height}` : "" });
97
102
  case "wake.heard":
98
- return clip(`"${d.text ?? ""}"${d.armed ? " · 말씀하세요 상태" : ""}${d.duringSpeech ? " · 음성 답변 중" : ""}`);
103
+ return clip(t("devlog.wakeHeard", { text: d.text ?? "", armed: d.armed ? ` · ${t("devlog.armed")}` : "", speaking: d.duringSpeech ? ` · ${t("devlog.speaking")}` : "" }));
99
104
  case "clap.double":
100
- return d.wakeWasOn ? "박수 두 번 → 호출어 끔" : "박수 두 번 → 호출어 켬";
105
+ return d.wakeWasOn ? t("devlog.clapWakeOff") : t("devlog.clapWakeOn");
101
106
  case "tts.summary":
102
107
  return clip(d.summary);
103
108
  case "input.text":
104
109
  return clip(d.text);
105
110
  case "input.action":
106
- return clip(`${d.actionId} → ${d.value ?? (d.confirmed !== undefined ? d.confirmed : "취소")}`);
111
+ return clip(`${d.actionId} → ${d.value ?? (d.confirmed !== undefined ? d.confirmed : t("devlog.cancel"))}`);
107
112
  case "hello":
108
113
  return clip(`${d.client} ${d.version}`);
109
114
  default:
@@ -124,3 +129,4 @@ export function describeEvent(dir, obj) {
124
129
  mergeKey: streaming ? `${type}:${data.itemId}` : null,
125
130
  };
126
131
  }
132
+
package/web/diagram.js CHANGED
@@ -1,3 +1,8 @@
1
+ import dict from "./i18n/diagram.js";
2
+ import { registerStrings, t } from "./i18n.js";
3
+
4
+ registerStrings(dict);
5
+
1
6
  // Diagram input is data only. Markdown recognition stays at the actual lexer boundary.
2
7
  const BODY_LIMIT = 16_384;
3
8
  const ID = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/;
@@ -128,12 +133,12 @@ function element(doc, tag, className, text) {
128
133
 
129
134
  export function createDiagram(doc, diagram, source) {
130
135
  const figure = element(doc, "figure", "chat-diagram");
131
- const title = diagram.title ?? "다이어그램";
136
+ const title = diagram.title ?? t("diagram.title.default");
132
137
  figure.append(element(doc, "figcaption", "diagram-title", title));
133
138
  const viewport = element(doc, "div", "diagram-viewport");
134
139
  viewport.tabIndex = 0;
135
140
  viewport.setAttribute("role", "region");
136
- viewport.setAttribute("aria-label", `${title}. 다이어그램 영역, 방향키로 스크롤할 수 있습니다.`);
141
+ viewport.setAttribute("aria-label", t("diagram.region", { title }));
137
142
  const stage = element(doc, "div", "diagram-stage");
138
143
  const svg = doc.createElementNS("http://www.w3.org/2000/svg", "svg");
139
144
  svg.setAttribute("class", "diagram-edges");
@@ -160,7 +165,7 @@ export function createDiagram(doc, diagram, source) {
160
165
  relationships.append(element(doc, "li", "", `${labels.get(edge.from)} → ${labels.get(edge.to)}`));
161
166
  }
162
167
  const details = element(doc, "details", "diagram-source");
163
- details.append(element(doc, "summary", "", "다이어그램 원문"));
168
+ details.append(element(doc, "summary", "", t("diagram.source")));
164
169
  const pre = element(doc, "pre");
165
170
  pre.append(element(doc, "code", "", source));
166
171
  details.append(pre);
package/web/enroll.js CHANGED
@@ -1,26 +1,47 @@
1
1
  // Plan Todo 15 (improvement 89): the 기기 등록 area of the web 설정 panel. Only decisions live here, with no DOM, so
2
2
  // test/unit/enroll-web.test.ts and the mutation run drive them directly; app.js paints the view it gets. The token
3
3
  // exists only inside the QR payload: nothing here turns it into text for the screen or the console.
4
+ import strings from "./i18n/enroll.js";
5
+ import { onLanguageChange, registerStrings, t } from "./i18n.js";
6
+
7
+ registerStrings(strings);
4
8
 
5
9
  export const ENROLL_TOKENS_PATH = "/api/enroll/tokens";
6
10
  export const DEVICES_PATH = "/api/devices";
7
11
  export const PAYLOAD_PREFIX = "zigmini://enroll?";
8
12
  export const QR_MARGIN = 4;
9
13
  export const REVOKE_ARM_MS = 5000;
10
- export const RENEW_LABEL = "새로 만들기";
11
- export const REVOKE_LABEL = "등록 취소";
12
- export const REVOKE_ARMED_LABEL = "다시 누르면 취소";
13
- export const HINT_LINE = "폰의 지그미니 앱에서 이 QR을 찍으세요. QR 하나는 10분 동안 한 번만 쓸 수 있습니다.";
14
- export const LOADING_LINE = "QR을 만드는 중…";
15
- export const EXPIRED_LINE = "QR이 만료되었습니다. 새로 만들어 다시 찍으세요.";
16
- export const NO_DEVICES_LINE = "등록된 기기가 없습니다.";
17
- export const NO_CONTACT_LINE = "접속 기록 없음";
14
+ export let RENEW_LABEL = t("enroll.button.renew");
15
+ export let REVOKE_LABEL = t("enroll.button.revoke");
16
+ export let REVOKE_ARMED_LABEL = t("enroll.button.revoke.armed");
17
+ export let HINT_LINE = t("enroll.hint");
18
+ export let LOADING_LINE = t("enroll.loading");
19
+ export let EXPIRED_LINE = t("enroll.expired");
20
+ export let NO_DEVICES_LINE = t("enroll.devices.empty");
21
+ export let NO_CONTACT_LINE = t("enroll.contact.none");
18
22
  export const ERROR_LINES = Object.freeze({
19
- access_url_unknown: "접속 주소를 알 수 없습니다. 맥이 Wi-Fi에 연결되어 있는지 확인한 뒤 새로 만드세요.",
20
- qr_unavailable: "QR 라이브러리를 불러오지 못했습니다. 페이지를 새로 고치세요.",
21
- network: "게이트웨이에 연결하지 못했습니다. 잠시 후 새로 만드세요.",
23
+ access_url_unknown: "enroll.error.access_url_unknown",
24
+ qr_unavailable: "enroll.error.qr_unavailable",
25
+ network: "enroll.error.network",
26
+ });
27
+ export let DEVICE_STATUS = deviceStatus();
28
+
29
+ function deviceStatus() {
30
+ return Object.freeze({ approved: t("enroll.device.approved"), pending: t("enroll.device.pending"), denied: t("enroll.device.denied") });
31
+ }
32
+
33
+ // The exported texts follow the UI language (app.js reads them at render time).
34
+ onLanguageChange(() => {
35
+ RENEW_LABEL = t("enroll.button.renew");
36
+ REVOKE_LABEL = t("enroll.button.revoke");
37
+ REVOKE_ARMED_LABEL = t("enroll.button.revoke.armed");
38
+ HINT_LINE = t("enroll.hint");
39
+ LOADING_LINE = t("enroll.loading");
40
+ EXPIRED_LINE = t("enroll.expired");
41
+ NO_DEVICES_LINE = t("enroll.devices.empty");
42
+ NO_CONTACT_LINE = t("enroll.contact.none");
43
+ DEVICE_STATUS = deviceStatus();
22
44
  });
23
- export const DEVICE_STATUS = Object.freeze({ approved: "승인됨", pending: "승인 대기", denied: "거절됨" });
24
45
 
25
46
  /** A token response as the screen needs it: the payload and the expiry as a time number. Null for any other shape. */
26
47
  export function parseEnrollment(body) {
@@ -42,7 +63,7 @@ export function formatLeft(seconds) {
42
63
  }
43
64
 
44
65
  export function errorLine(error) {
45
- return ERROR_LINES[error] ?? `QR을 만들지 못했습니다 (${error}). 새로 만들기를 누르세요.`;
66
+ return t(ERROR_LINES[error] ?? "enroll.error.generic", { error });
46
67
  }
47
68
 
48
69
  /**
@@ -50,12 +71,12 @@ export function errorLine(error) {
50
71
  * 새로 만들기 button. The QR and the button never show together, and an expired or failed token leaves only the button.
51
72
  */
52
73
  export function enrollView(state, now) {
53
- if (state.status === "loading") return { qr: false, countdown: null, renew: false, expired: false, error: false, message: LOADING_LINE };
74
+ if (state.status === "loading") return { qr: false, countdown: null, renew: false, expired: false, error: false, message: t("enroll.loading") };
54
75
  if (state.status === "error") return { qr: false, countdown: null, renew: true, expired: false, error: true, message: errorLine(state.error) };
55
- if (!state.enrollment) return { qr: false, countdown: null, renew: true, expired: false, error: false, message: HINT_LINE };
76
+ if (!state.enrollment) return { qr: false, countdown: null, renew: true, expired: false, error: false, message: t("enroll.hint") };
56
77
  const left = secondsLeft(state.enrollment.expiresAt, now);
57
- if (left === 0) return { qr: false, countdown: null, renew: true, expired: true, error: false, message: EXPIRED_LINE };
58
- return { qr: true, countdown: `남은 시간 ${formatLeft(left)}`, renew: false, expired: false, error: false, message: HINT_LINE };
78
+ if (left === 0) return { qr: false, countdown: null, renew: true, expired: true, error: false, message: t("enroll.expired") };
79
+ return { qr: true, countdown: t("enroll.countdown", { time: formatLeft(left) }), renew: false, expired: false, error: false, message: t("enroll.hint") };
59
80
  }
60
81
 
61
82
  /** The payload as a scalable SVG from the vendored qrcode-generator: black modules on white with a quiet zone. */
@@ -78,10 +99,10 @@ export function lastSeenLabel(iso, now) {
78
99
  const at = typeof iso === "string" ? Date.parse(iso) : NaN;
79
100
  if (!Number.isFinite(at)) return NO_CONTACT_LINE;
80
101
  const seconds = Math.floor((now - at) / 1000);
81
- if (seconds < 60) return "방금 접속";
82
- if (seconds < 3600) return `${Math.floor(seconds / 60)}분 전 접속`;
83
- if (seconds < 86400) return `${Math.floor(seconds / 3600)}시간 전 접속`;
84
- return `${Math.floor(seconds / 86400)}일 전 접속`;
102
+ if (seconds < 60) return t("enroll.contact.now");
103
+ if (seconds < 3600) return t("enroll.contact.minutes", { count: Math.floor(seconds / 60) });
104
+ if (seconds < 86400) return t("enroll.contact.hours", { count: Math.floor(seconds / 3600) });
105
+ return t("enroll.contact.days", { count: Math.floor(seconds / 86400) });
85
106
  }
86
107
 
87
108
  function contactTime(device) {
@@ -100,7 +121,7 @@ export function deviceRows(devices, now) {
100
121
  .map((device) => ({
101
122
  id: device.id,
102
123
  name: device.name,
103
- model: device.model ?? "모델 정보 없음",
124
+ model: device.model ?? t("enroll.device.model.unknown"),
104
125
  lastSeen: lastSeenLabel(device.lastSeen, now),
105
126
  status: DEVICE_STATUS[device.status] ?? device.status,
106
127
  approved: device.status === "approved",