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
package/web/memo.js CHANGED
@@ -1,3 +1,8 @@
1
+ import { registerStrings, t } from "./i18n.js";
2
+ import strings from "./i18n/memo.js";
3
+
4
+ registerStrings(strings);
5
+
1
6
  const STORAGE_KEY = "zgw.memo";
2
7
  const MAX_IMAGES = 2;
3
8
  const IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
@@ -35,13 +40,13 @@ export function setupMemo({ uploadImage, reportError }) {
35
40
  link.rel = "noopener";
36
41
  const image = document.createElement("img");
37
42
  image.src = link.href;
38
- image.alt = `보관한 이미지 ${index + 1}`;
43
+ image.alt = t("memo.savedImage", { index: index + 1 });
39
44
  link.append(image);
40
45
  const remove = document.createElement("button");
41
46
  remove.type = "button";
42
47
  remove.className = "text-btn";
43
- remove.textContent = "삭제";
44
- remove.setAttribute("aria-label", `보관한 이미지 ${index + 1} 삭제`);
48
+ remove.textContent = t("memo.delete");
49
+ remove.setAttribute("aria-label", t("memo.deleteImage", { index: index + 1 }));
45
50
  remove.disabled = imageBusy;
46
51
  remove.onclick = () => saveImages(images.filter((_, at) => at !== index));
47
52
  item.append(link, remove);
@@ -83,20 +88,20 @@ export function setupMemo({ uploadImage, reportError }) {
83
88
  pendingText = null;
84
89
  void put({ text: value }, keepalive).catch((error) => {
85
90
  pendingText ??= value;
86
- fail("쪽지 저장 실패", error);
91
+ fail(t("memo.saveFailed"), error);
87
92
  });
88
93
  }
89
94
  async function saveImages(next) {
90
95
  if (imageBusy) return;
91
96
  imageBusy = true;
92
- status.textContent = "이미지 저장 중";
97
+ status.textContent = t("memo.savingImage");
93
98
  renderImages();
94
99
  try {
95
100
  const saved = await put({ images: next });
96
101
  images = saved.images;
97
102
  status.textContent = "";
98
103
  } catch (error) {
99
- fail("이미지 저장 실패", error);
104
+ fail(t("memo.imageSaveFailed"), error);
100
105
  } finally {
101
106
  imageBusy = false;
102
107
  renderImages();
@@ -106,15 +111,15 @@ export function setupMemo({ uploadImage, reportError }) {
106
111
  const files = [...selected];
107
112
  if (imageBusy || files.length === 0) return;
108
113
  if (images.length + files.length > MAX_IMAGES) {
109
- status.textContent = "이미지는 두 개까지 보관할 수 있습니다.";
114
+ status.textContent = t("memo.imageLimit");
110
115
  return;
111
116
  }
112
117
  if (files.some((file) => !IMAGE_TYPES.has(file.type))) {
113
- status.textContent = "PNG, JPEG, GIF, WebP 이미지를 선택해 주세요.";
118
+ status.textContent = t("memo.imageTypes");
114
119
  return;
115
120
  }
116
121
  imageBusy = true;
117
- status.textContent = "이미지 업로드 중";
122
+ status.textContent = t("memo.uploadingImage");
118
123
  renderImages();
119
124
  try {
120
125
  const next = [...images];
@@ -123,7 +128,7 @@ export function setupMemo({ uploadImage, reportError }) {
123
128
  images = saved.images;
124
129
  status.textContent = "";
125
130
  } catch (error) {
126
- fail("이미지 보관 실패", error);
131
+ fail(t("memo.imageStoreFailed"), error);
127
132
  } finally {
128
133
  imageBusy = false;
129
134
  renderImages();
@@ -139,7 +144,7 @@ export function setupMemo({ uploadImage, reportError }) {
139
144
  rememberOpen(value);
140
145
  if (value) {
141
146
  text.focus();
142
- void pull().catch((error) => fail("쪽지 불러오기 실패", error));
147
+ void pull().catch((error) => fail(t("memo.loadFailed"), error));
143
148
  } else flush();
144
149
  }
145
150
 
@@ -161,7 +166,7 @@ export function setupMemo({ uploadImage, reportError }) {
161
166
  if (!value.width && legacy.width) Object.assign(patch, { width: legacy.width, height: legacy.height });
162
167
  if (Object.keys(patch).length) { apply(patch); await put(patch); }
163
168
  rememberOpen(Boolean(saved.open));
164
- }).catch((error) => fail("쪽지 불러오기 실패", error));
169
+ }).catch((error) => fail(t("memo.loadFailed"), error));
165
170
  toggle.onclick = () => open(box.hidden);
166
171
  add.onclick = () => picker.click();
167
172
  picker.onchange = () => { void addImages(picker.files); picker.value = ""; };
@@ -201,7 +206,7 @@ export function setupMemo({ uploadImage, reportError }) {
201
206
  handle.removeEventListener("pointermove", move);
202
207
  handle.removeEventListener("pointerup", end);
203
208
  handle.removeEventListener("pointercancel", end);
204
- void put({ width: box.offsetWidth, height: box.offsetHeight }).catch((error) => fail("쪽지 크기 저장 실패", error));
209
+ void put({ width: box.offsetWidth, height: box.offsetHeight }).catch((error) => fail(t("memo.resizeFailed"), error));
205
210
  };
206
211
  handle.addEventListener("pointermove", move);
207
212
  handle.addEventListener("pointerup", end);
@@ -1,3 +1,8 @@
1
+ import dict from "./i18n/notifications.js";
2
+ import { registerStrings, t } from "./i18n.js";
3
+
4
+ registerStrings(dict);
5
+
1
6
  export const NOTIFICATION_SETTINGS_PATH = "/api/settings/notifications";
2
7
 
3
8
  /** Parse gateway data without treating a failed or malformed response as a default. */
@@ -12,17 +17,17 @@ export function createNotificationSettings(fetcher, render) {
12
17
  let mode = "inbox-only";
13
18
  let ready = false;
14
19
  let online = false;
15
- let error = "";
20
+ let errorKey = "";
16
21
  let epoch = 0;
17
22
  let generation = 0;
18
23
  let pending = 0;
19
24
  let tail = Promise.resolve();
20
- const view = () => ({ mode, ready, online, busy: pending > 0, error });
25
+ const view = () => ({ mode, ready, online, busy: pending > 0, error: errorKey ? t(errorKey) : "" });
21
26
  const paint = () => render(view());
22
27
  const confirm = (value) => {
23
28
  mode = value;
24
29
  ready = true;
25
- error = "";
30
+ errorKey = "";
26
31
  paint();
27
32
  };
28
33
 
@@ -38,7 +43,7 @@ export function createNotificationSettings(fetcher, render) {
38
43
  if (connection === epoch && request === generation) confirm(value);
39
44
  } catch {
40
45
  if (connection === epoch && request === generation) {
41
- error = "알림 설정을 불러오지 못했습니다. 다시 연결하면 재시도합니다.";
46
+ errorKey = "notifications.error.read";
42
47
  paint();
43
48
  }
44
49
  }
@@ -65,7 +70,7 @@ export function createNotificationSettings(fetcher, render) {
65
70
  else await read(); // A newer event/read may describe another client's write.
66
71
  } catch {
67
72
  if (connection === epoch) {
68
- error = "알림 설정을 저장하지 못했습니다. 확인된 설정을 유지합니다.";
73
+ errorKey = "notifications.error.save";
69
74
  paint();
70
75
  }
71
76
  } finally {
@@ -86,7 +91,7 @@ export function createNotificationSettings(fetcher, render) {
86
91
  generation += 1;
87
92
  online = true;
88
93
  ready = false;
89
- error = "";
94
+ errorKey = "";
90
95
  paint();
91
96
  return read();
92
97
  },
@@ -110,7 +115,7 @@ export function setupNotificationSettings({ choice, status }, fetcher = fetch) {
110
115
  const controller = createNotificationSettings(fetcher, (view) => {
111
116
  choice.value = view.mode;
112
117
  choice.disabled = !view.online || !view.ready || view.busy;
113
- status.textContent = view.error || (view.busy ? "저장 중…" : !view.online ? "게이트웨이 연결을 기다립니다." : !view.ready ? "알림 설정 확인 중…" : "");
118
+ status.textContent = view.error || (view.busy ? t("notifications.saving") : !view.online ? t("notifications.waiting") : !view.ready ? t("notifications.checking") : "");
114
119
  status.classList.toggle("error", Boolean(view.error));
115
120
  });
116
121
  choice.onchange = () => controller.set(choice.value);
package/web/profile.js CHANGED
@@ -1,18 +1,45 @@
1
- // The user's profile nickname: shown in the sidebar profile, on the user's chat messages and on the 작업 상태 user card.
1
+ import dict from "./i18n/profile.js";
2
+ import { onLanguageChange, registerStrings, t } from "./i18n.js";
3
+
4
+ registerStrings(dict);
5
+
6
+ // The user's profile nickname: shown in the sidebar profile, on the user's chat messages and on the work status user card.
2
7
  // Double-clicking the profile edits it; the name is kept in this browser's localStorage.
3
8
  // The agent's (gateway main) name sits beside it with the same editing; the gateway applies the same rules (src/profile.ts).
4
9
 
5
10
  export const NICKNAME_KEY = "zgw.profile.nickname";
6
- export const DEFAULT_NICKNAME = "나";
11
+ /** A live binding: it follows the UI language, so importers always read the current default. */
12
+ export let DEFAULT_NICKNAME = t("profile.nickname.default");
7
13
  export const NICKNAME_MAX = 20;
14
+ export const AGENT_NAME_KEY = "zgw.profile.agentName";
15
+ /** A live binding like DEFAULT_NICKNAME. */
16
+ export let DEFAULT_AGENT_NAME = t("profile.agent.default");
17
+ onLanguageChange(() => {
18
+ DEFAULT_NICKNAME = t("profile.nickname.default");
19
+ DEFAULT_AGENT_NAME = t("profile.agent.default");
20
+ });
21
+
22
+ /** True when [name] is a built-in default of either language (the same rule as src/profile.ts). */
23
+ function isBuiltIn(name, key) {
24
+ return name === dict.en[key] || name === dict.ko[key];
25
+ }
8
26
 
9
- export function normalizeNickname(value) {
27
+ /** The user's own nickname: whitespace collapsed and trimmed, at most NICKNAME_MAX characters; "" for blank or a built-in default. */
28
+ function ownNickname(value) {
29
+ const name = typeof value === "string" ? [...value.replace(/\s+/g, " ").trim()].slice(0, NICKNAME_MAX).join("") : "";
30
+ return isBuiltIn(name, "profile.nickname.default") ? "" : name;
31
+ }
32
+
33
+ function ownAgentName(value) {
10
34
  const name = typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
11
- return name ? [...name].slice(0, NICKNAME_MAX).join("") : DEFAULT_NICKNAME;
35
+ return isBuiltIn(name, "profile.agent.default") ? "" : name;
36
+ }
37
+
38
+ /** The nickname to show: the user's own, else the current language's default. */
39
+ export function normalizeNickname(value) {
40
+ return ownNickname(value) || DEFAULT_NICKNAME;
12
41
  }
13
42
 
14
- export const AGENT_NAME_KEY = "zgw.profile.agentName";
15
- export const DEFAULT_AGENT_NAME = "지그";
16
43
  export const AGENT_NAME_WIDTH = 20;
17
44
 
18
45
  /** Same ranges as src/profile.ts: Hangul, CJK, full-width forms and wide emoji count 2. */
@@ -29,9 +56,9 @@ export function displayWidth(text) {
29
56
  return width;
30
57
  }
31
58
 
32
- /** Whitespace collapsed and trimmed; blank becomes the default. A name wider than AGENT_NAME_WIDTH is not shortened. */
59
+ /** Whitespace collapsed and trimmed; blank or a built-in default becomes the current default. A name wider than AGENT_NAME_WIDTH is not shortened. */
33
60
  export function normalizeAgentName(value) {
34
- return (typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "") || DEFAULT_AGENT_NAME;
61
+ return ownAgentName(value) || DEFAULT_AGENT_NAME;
35
62
  }
36
63
 
37
64
  export function agentNameFits(value) {
@@ -39,14 +66,13 @@ export function agentNameFits(value) {
39
66
  }
40
67
 
41
68
  export function readAgentName(storage) {
42
- const name = normalizeAgentName(storage.getItem(AGENT_NAME_KEY));
43
- return agentNameFits(name) ? name : DEFAULT_AGENT_NAME;
69
+ const name = ownAgentName(storage.getItem(AGENT_NAME_KEY));
70
+ return name && agentNameFits(name) ? name : DEFAULT_AGENT_NAME;
44
71
  }
45
72
 
46
73
  export function writeAgentName(storage, value) {
47
- const name = normalizeAgentName(value);
48
- storage.setItem(AGENT_NAME_KEY, name);
49
- return name;
74
+ storage.setItem(AGENT_NAME_KEY, ownAgentName(value));
75
+ return normalizeAgentName(value);
50
76
  }
51
77
 
52
78
  /**
@@ -94,9 +120,8 @@ export function readNickname(storage) {
94
120
  }
95
121
 
96
122
  export function writeNickname(storage, value) {
97
- const name = normalizeNickname(value);
98
- storage.setItem(NICKNAME_KEY, name);
99
- return name;
123
+ storage.setItem(NICKNAME_KEY, ownNickname(value));
124
+ return normalizeNickname(value);
100
125
  }
101
126
 
102
127
  export function nicknameInitial(name) {
@@ -115,7 +140,7 @@ export function editNickname(nameEl, current, onSave, options = {}) {
115
140
  input.className = "me-name-input";
116
141
  input.value = current;
117
142
  input.maxLength = NICKNAME_MAX * 2;
118
- input.setAttribute("aria-label", options.label ?? "프로필 닉네임");
143
+ input.setAttribute("aria-label", options.label ?? t("profile.nickname.label"));
119
144
  const mark = () => input.setAttribute("aria-invalid", String(!fits(input.value)));
120
145
  input.addEventListener("input", mark);
121
146
  mark();
package/web/reference.js CHANGED
@@ -1,4 +1,9 @@
1
- // Message references (backlog 28): a right-click (or the row's 참조 button) on a stored message of the open conversation
1
+ import { registerStrings, t } from "./i18n.js";
2
+ import strings from "./i18n/reference.js";
3
+
4
+ registerStrings(strings);
5
+
6
+ // Message references (backlog 28): a right-click (or the row's reference button) on a stored message of the open conversation
2
7
  // adds it as a removable tag above the composer. A send carries the sources' stored event seqs
3
8
  // (`input.text.data.references`); the gateway stores canonical snapshots on the user message
4
9
  // (`message.user.data.references`), which history shows as quotes. Nothing is copied into the draft text.
@@ -7,13 +12,13 @@ export const MAX_REFERENCES = 4;
7
12
  // The gateway forwards the tagged sources' images with the new attachments and admits at most this many in one message.
8
13
  export const MAX_IMAGES = 4;
9
14
  // The assistant label is the agent's profile name (improvement 87): app.js passes `{ ...ROLE_LABELS, assistant: agentName }`.
10
- export const ROLE_LABELS = { user: "나", assistant: "zigmini" };
11
- export const NOTICES = {
12
- duplicate: "이미 참조한 메시지입니다.",
13
- full: `참조는 한 번에 ${MAX_REFERENCES}개까지 넣을 수 있습니다.`,
14
- locked: "이미지를 올리는 동안에는 참조를 바꿀 수 없습니다.",
15
- images: `이미지는 참조한 메시지의 이미지까지 합쳐 한 번에 ${MAX_IMAGES}장까지 보낼 수 있습니다. 참조나 첨부를 줄여 주세요.`,
16
- };
15
+ export const ROLE_LABELS = { get user() { return t("reference.user"); }, assistant: "zigmini" };
16
+ export const NOTICES = Object.defineProperties({}, {
17
+ duplicate: { enumerable: true, get: () => t("reference.duplicate") },
18
+ full: { enumerable: true, get: () => t("reference.full", { count: MAX_REFERENCES }) },
19
+ locked: { enumerable: true, get: () => t("reference.locked") },
20
+ images: { enumerable: true, get: () => t("reference.images", { count: MAX_IMAGES }) },
21
+ });
17
22
 
18
23
  /**
19
24
  * The reference a stored chat event can be the source of, or null. Only an event with a stored seq counts: a user
@@ -42,12 +47,12 @@ function imageNames(list) {
42
47
 
43
48
  /** One line for a tag: the image count first, then the text with its whitespace collapsed (CSS clips the rest). */
44
49
  export function referenceLabel(ref) {
45
- const image = ref.images.length > 0 ? `[이미지 ${ref.images.length}장]` : "";
50
+ const image = ref.images.length > 0 ? t("reference.imageCount", { count: ref.images.length }) : "";
46
51
  return [image, ref.text.replace(/\s+/g, " ").trim()].filter(Boolean).join(" ");
47
52
  }
48
53
 
49
54
  export function referenceTitle(ref) {
50
- const image = ref.images.length > 0 ? `[이미지 ${ref.images.length}장]` : "";
55
+ const image = ref.images.length > 0 ? t("reference.imageCount", { count: ref.images.length }) : "";
51
56
  return [image, ref.text.trim()].filter(Boolean).join("\n");
52
57
  }
53
58
 
@@ -148,7 +153,7 @@ export function sentReferences(data) {
148
153
  // A source row's data comes from its event, never read back from what the row shows.
149
154
  const rowSources = new WeakMap();
150
155
 
151
- /** Marks [row] as a source and gives it the 참조 button: the keyboard route to the same tag as a right-click. */
156
+ /** Marks [row] as a source and gives it the reference button: the keyboard route to the same tag as a right-click. */
152
157
  export function markReferenceable(row, source) {
153
158
  rowSources.set(row, source);
154
159
  row.dataset.refSeq = String(source.seq);
@@ -156,8 +161,8 @@ export function markReferenceable(row, source) {
156
161
  const button = document.createElement("button");
157
162
  button.type = "button";
158
163
  button.className = "msg-ref-btn";
159
- button.textContent = "참조";
160
- button.title = "이 메시지를 입력창에 참조로 넣기";
164
+ button.textContent = t("reference.button");
165
+ button.title = t("reference.buttonTitle");
161
166
  row.querySelector(".msg-head")?.append(button);
162
167
  }
163
168
 
@@ -184,8 +189,8 @@ function referenceChip(ref, locked, onRemove, labels) {
184
189
  remove.type = "button";
185
190
  remove.className = "ref-tag-remove";
186
191
  remove.textContent = "×";
187
- remove.title = "참조 빼기";
188
- remove.setAttribute("aria-label", `참조 빼기: ${label}`);
192
+ remove.title = t("reference.remove");
193
+ remove.setAttribute("aria-label", t("reference.removeLabel", { label }));
189
194
  remove.disabled = locked;
190
195
  remove.onclick = () => onRemove(ref.seq, chip);
191
196
  chip.addEventListener("keydown", (ev) => {
@@ -1,8 +1,10 @@
1
1
  import type { HeldState, AllowedValue } from "../src/security-allow.ts";
2
2
  import type { HeldMessage } from "../src/security-check.ts";
3
+ import type { SecurityBaselineTerm } from "../src/store.ts";
3
4
 
4
5
  export const HELD_PATH: "/api/security-check/held";
5
6
  export const ALLOWED_PATH: "/api/settings/security-check/allowed-values";
7
+ export const BASELINE_PATH: "/api/settings/security-check/baseline";
6
8
  export type HeldDetail = HeldState & Partial<Pick<HeldMessage, "occurrences" | "draft">>;
7
9
  export interface Entry {
8
10
  held: HeldDetail;
@@ -29,7 +31,22 @@ export function createSecurityInbox(fetcher: Fetcher, changed?: () => void): {
29
31
  supported(value: boolean): void; connect(): Promise<void>; disconnect(): void; hide(id: string): boolean;
30
32
  };
31
33
  export function createAllowedValues(fetcher: Fetcher, changed?: () => void): {
32
- state: { revision: number; values: AllowedValue[]; online: boolean; busy: boolean; loading: boolean; error: string; ready: boolean };
34
+ state: { revision: number; values: AllowedValue[]; online: boolean; busy: boolean; loading: boolean; error: string; notice: string; ready: boolean };
33
35
  refresh(): Promise<void>; connect(): Promise<void>; disconnect(): void; updated(revision: number): void;
34
36
  remove(id?: string | null): Promise<void>;
37
+ setScope(id: string, repo: boolean): Promise<boolean>;
38
+ };
39
+ export type AllowScope = NonNullable<AllowedValue["scopes"]>[number];
40
+ export function repoAllowed(value: Pick<AllowedValue, "scopes">): boolean;
41
+ export function scopesFor(repo: boolean): AllowScope[];
42
+ export function repoScopeBlock(value: string, auto: BaselineState["auto"]): string;
43
+ export interface BaselineState {
44
+ revision: number; terms: SecurityBaselineTerm[]; auto: { home: string; user: string } | null;
45
+ exportedAt: string | null; exportError: string | null;
46
+ online: boolean; busy: boolean; loading: boolean; error: string; notice: string; ready: boolean;
47
+ }
48
+ export function createSecurityBaseline(fetcher: Fetcher, changed?: () => void): {
49
+ state: BaselineState;
50
+ refresh(): Promise<void>; connect(): Promise<void>; disconnect(): void; updated(revision: number): void;
51
+ add(value: string): Promise<boolean>; remove(id: string): Promise<boolean>;
35
52
  };
@@ -1,6 +1,12 @@
1
+ import { registerStrings, t } from "./i18n.js";
2
+ import strings from "./i18n/security-inbox.js";
3
+
4
+ registerStrings(strings);
5
+
1
6
  // Installation-local, memory-only client state. Never persist held details or drafts.
2
7
  export const HELD_PATH = "/api/security-check/held";
3
8
  export const ALLOWED_PATH = "/api/settings/security-check/allowed-values";
9
+ export const BASELINE_PATH = "/api/settings/security-check/baseline";
4
10
  const TERMINAL = new Set(["sent", "cancelled", "failed", "unconfirmed"]);
5
11
  const STATUSES = new Set(["pending", "processing", ...TERMINAL]);
6
12
 
@@ -170,7 +176,7 @@ export function createSecurityInbox(fetcher, changed = () => {}) {
170
176
  }
171
177
 
172
178
  export function createAllowedValues(fetcher, changed = () => {}) {
173
- const state = { revision: -1, values: [], online: false, busy: false, loading: false, error: "", ready: false };
179
+ const state = { revision: -1, values: [], online: false, busy: false, loading: false, error: "", notice: "", ready: false };
174
180
  let generation = 0;
175
181
  let read = 0;
176
182
  let wanted = -1;
@@ -180,6 +186,7 @@ export function createAllowedValues(fetcher, changed = () => {}) {
180
186
  const request = ++read;
181
187
  state.loading = true;
182
188
  state.error = "";
189
+ state.notice = "";
183
190
  changed();
184
191
  try {
185
192
  const response = await fetcher(ALLOWED_PATH, { cache: "no-store", signal: AbortSignal.timeout(30_000) });
@@ -190,9 +197,9 @@ export function createAllowedValues(fetcher, changed = () => {}) {
190
197
  state.revision = body.revision;
191
198
  state.values = body.values;
192
199
  state.ready = true;
193
- } else state.error = "목록이 변경됐습니다. 다시 확인하세요.";
200
+ } else state.error = t("securityInbox.listChanged");
194
201
  } catch {
195
- if (epoch === generation && request === read) state.error = "허용 값을 확인하지 못했습니다. 다시 시도하세요.";
202
+ if (epoch === generation && request === read) state.error = t("securityInbox.allowedReadFailed");
196
203
  } finally {
197
204
  if (epoch === generation && request === read) {
198
205
  state.loading = false;
@@ -228,10 +235,172 @@ export function createAllowedValues(fetcher, changed = () => {}) {
228
235
  wanted = Math.max(wanted, body.revision);
229
236
  await refresh();
230
237
  } catch {
231
- if (epoch === generation) state.error = "삭제 결과를 확인하지 못했습니다. 목록을 다시 확인하세요. 삭제를 자동 반복하지 않습니다.";
238
+ if (epoch === generation) state.error = t("securityInbox.deleteUnconfirmed");
232
239
  } finally {
233
240
  if (epoch === generation) { state.busy = false; changed(); }
234
241
  }
235
242
  },
243
+ /** One PATCH against the shown revision. Resolves true only when the server applied it; 409 and 404 re-read the newest list. */
244
+ setScope: async (id, repo) => {
245
+ // ready implies online: disconnect clears it, and a read applies it only inside its own generation.
246
+ if (!state.ready || state.busy || state.loading || state.error) return false;
247
+ state.busy = true;
248
+ state.notice = "";
249
+ const epoch = generation;
250
+ changed();
251
+ try {
252
+ const response = await fetcher(`${ALLOWED_PATH}/${encodeURIComponent(id)}`, {
253
+ method: "PATCH", headers: { "content-type": "application/json" },
254
+ body: JSON.stringify({ expectedRevision: state.revision, scopes: scopesFor(repo) }), signal: AbortSignal.timeout(30_000),
255
+ });
256
+ const body = await response.json();
257
+ if (epoch !== generation) return false;
258
+ if (response.status === 400) {
259
+ state.notice = t("securityInbox.scopeRejected", { error: body?.error ?? "scopes_invalid" });
260
+ return false;
261
+ }
262
+ const stale = response.status === 409 || response.status === 404;
263
+ if (!response.ok && !stale) throw new Error("scope_unconfirmed");
264
+ if (Number.isSafeInteger(body?.revision)) wanted = Math.max(wanted, body.revision);
265
+ await refresh();
266
+ if (stale && epoch === generation) state.notice = t("securityInbox.listRefreshed");
267
+ return response.ok;
268
+ } catch {
269
+ if (epoch === generation) state.error = t("securityInbox.scopeUnconfirmed");
270
+ return false;
271
+ } finally {
272
+ if (epoch === generation) { state.busy = false; changed(); }
273
+ }
274
+ },
275
+ };
276
+ }
277
+
278
+ /** Whether a remembered value also passes the repository scan; a row saved without scopes carries the transport default only. */
279
+ export function repoAllowed(value) {
280
+ return Array.isArray(value.scopes) && value.scopes.includes("repo");
281
+ }
282
+
283
+ /** The scopes the toggle writes: on adds "repo" beside the transport default, off returns to the default alone. */
284
+ export function scopesFor(repo) {
285
+ return repo ? ["transport", "repo"] : ["transport"];
286
+ }
287
+
288
+ /** The repository scan's term normalization (src/security/terms.ts normalizeTerm), so the screen judges like the export. */
289
+ const normalizeTerm = (value) => value.normalize("NFC").toLowerCase();
290
+
291
+ /**
292
+ * Why this value's repository toggle stays disabled, or "" when it may switch. The judgment is the export's
293
+ * (src/security-baseline.ts isAutomaticBaselineValue): the home path and the account name stay protected, and an
294
+ * unread automatic list fails closed.
295
+ */
296
+ export function repoScopeBlock(value, auto) {
297
+ if (!auto) return t("securityInbox.autoUnavailable");
298
+ const normalized = normalizeTerm(value.trim());
299
+ if (normalized === normalizeTerm(auto.home) || normalized === normalizeTerm(auto.user)) {
300
+ return t("securityInbox.autoProtected");
301
+ }
302
+ return "";
303
+ }
304
+
305
+ /** The screen's words for a server rejection; the raw reason stays beside them. */
306
+ const TERM_REJECTIONS = { empty: "securityInbox.rejectEmpty", too_short: "securityInbox.rejectShort", loopback: "securityInbox.rejectLoopback" };
307
+
308
+ /**
309
+ * The repository baseline of this installation: its terms, the read-only automatic items and the exported file's state.
310
+ * Terms share the allowed values' revision, so a stale write gets 409 and re-reads instead of retrying on top of it.
311
+ */
312
+ export function createSecurityBaseline(fetcher, changed = () => {}) {
313
+ const state = {
314
+ revision: -1, terms: [], auto: null, exportedAt: null, exportError: null,
315
+ online: false, busy: false, loading: false, error: "", notice: "", ready: false,
316
+ };
317
+ let generation = 0;
318
+ let read = 0;
319
+ let wanted = -1;
320
+ async function refresh() {
321
+ if (!state.online) return;
322
+ const epoch = generation;
323
+ const request = ++read;
324
+ state.loading = true;
325
+ state.error = "";
326
+ state.notice = "";
327
+ changed();
328
+ try {
329
+ const response = await fetcher(BASELINE_PATH, { cache: "no-store", signal: AbortSignal.timeout(30_000) });
330
+ const body = await response.json();
331
+ if (epoch !== generation || request !== read) return;
332
+ if (!response.ok || !Number.isSafeInteger(body.revision) || !Array.isArray(body.terms)) throw new Error("list_unavailable");
333
+ if (body.revision >= state.revision && body.revision >= wanted) {
334
+ state.revision = body.revision;
335
+ state.terms = body.terms;
336
+ state.auto = body.auto ?? null;
337
+ state.exportedAt = body.exportedAt ?? null;
338
+ state.exportError = body.exportError ?? null;
339
+ state.ready = true;
340
+ } else state.error = t("securityInbox.listChanged");
341
+ } catch {
342
+ if (epoch === generation && request === read) state.error = t("securityInbox.baselineReadFailed");
343
+ } finally {
344
+ if (epoch === generation && request === read) {
345
+ state.loading = false;
346
+ changed();
347
+ }
348
+ }
349
+ }
350
+ /** One write against the shown revision. Resolves true only when the server applied it; 409 and 404 re-read the newest list. */
351
+ async function write(path, method, payload, unconfirmed) {
352
+ // ready implies online: disconnect clears it, and a read applies it only inside its own generation.
353
+ if (!state.ready || state.busy || state.loading || state.error) return false;
354
+ state.busy = true;
355
+ state.notice = "";
356
+ const epoch = generation;
357
+ changed();
358
+ try {
359
+ const response = await fetcher(path, {
360
+ method, headers: { "content-type": "application/json" },
361
+ body: JSON.stringify({ ...payload, expectedRevision: state.revision }), signal: AbortSignal.timeout(30_000),
362
+ });
363
+ const body = await response.json();
364
+ if (epoch !== generation) return false;
365
+ if (response.status === 400) {
366
+ state.notice = body?.error === "term_duplicate" ? t("securityInbox.termDuplicate")
367
+ : t("securityInbox.termRejected", { reason: body?.reason ?? body?.error, detail: TERM_REJECTIONS[body?.reason] ? t(TERM_REJECTIONS[body.reason]) : t("securityInbox.rejectDefault") });
368
+ return false;
369
+ }
370
+ const stale = response.status === 409 || response.status === 404;
371
+ if (!response.ok && !stale) throw new Error("write_unconfirmed");
372
+ if (Number.isSafeInteger(body?.revision)) wanted = Math.max(wanted, body.revision);
373
+ await refresh();
374
+ if (stale && epoch === generation) state.notice = t("securityInbox.listRefreshed");
375
+ return response.ok;
376
+ } catch {
377
+ if (epoch === generation) state.error = unconfirmed;
378
+ return false;
379
+ } finally {
380
+ if (epoch === generation) { state.busy = false; changed(); }
381
+ }
382
+ }
383
+ return {
384
+ state, refresh,
385
+ connect: () => { generation++; state.online = true; return refresh(); },
386
+ disconnect: () => {
387
+ generation++; state.online = false; state.busy = false; state.loading = false; state.ready = false; changed();
388
+ },
389
+ updated: (revision) => {
390
+ if (revision <= Math.max(wanted, state.revision)) return;
391
+ wanted = revision;
392
+ void refresh();
393
+ },
394
+ add: async (value) => {
395
+ const term = typeof value === "string" ? value.trim() : "";
396
+ if (term === "") {
397
+ state.notice = t("securityInbox.enterTerm");
398
+ changed();
399
+ return false;
400
+ }
401
+ return write(BASELINE_PATH, "POST", { value: term }, t("securityInbox.addUnconfirmed"));
402
+ },
403
+ remove: (id) => write(`${BASELINE_PATH}/${encodeURIComponent(id)}`, "DELETE", {},
404
+ t("securityInbox.deleteUnconfirmed")),
236
405
  };
237
406
  }
package/web/slash.js CHANGED
@@ -1,19 +1,29 @@
1
+ import { registerStrings, t } from "./i18n.js";
2
+ import strings from "./i18n/slash.js";
3
+
4
+ registerStrings(strings);
5
+
1
6
  export const WEB_COMMANDS = [
2
- { name: "tts on", description: "음성 답변 켜기", source: "web" },
3
- { name: "tts off", description: "음성 답변 끄기", source: "web" },
4
- { name: "wake on", description: "호출어(헤이 지그) 대기 켜기", source: "web" },
5
- { name: "wake off", description: "호출어(헤이 지그) 대기 끄기", source: "web" },
6
- { name: "clap on", description: "박수 감지 켜기", source: "web" },
7
- { name: "clap off", description: "박수 감지 끄기", source: "web" },
8
- { name: "voice on", description: "음성 기능 켜기(음성 서버 시작)", source: "web" },
9
- { name: "voice off", description: "음성 기능 끄기(음성 서버 정지)", source: "web" },
10
- { name: "옵션", description: "음성 기능·음성 답변·호출어·박수 감지 상태 보기", source: "web" },
11
- { name: "read", description: "마지막 답변 전체를 음성으로 읽기", source: "web" },
12
- { name: "기기", description: "등록된 기기 목록 (맥에서만)", source: "web" },
13
- { name: "기기 삭제", description: "번호로 등록된 기기 삭제, 예: /기기 삭제 2 (맥에서만)", source: "web" },
7
+ { name: "tts on", get description() { return t("slash.ttsOn"); }, source: "web" },
8
+ { name: "tts off", get description() { return t("slash.ttsOff"); }, source: "web" },
9
+ { name: "wake on", get description() { return t("slash.wakeOn"); }, source: "web" },
10
+ { name: "wake off", get description() { return t("slash.wakeOff"); }, source: "web" },
11
+ { name: "clap on", get description() { return t("slash.clapOn"); }, source: "web" },
12
+ { name: "clap off", get description() { return t("slash.clapOff"); }, source: "web" },
13
+ { name: "voice on", get description() { return t("slash.voiceOn"); }, source: "web" },
14
+ { name: "voice off", get description() { return t("slash.voiceOff"); }, source: "web" },
15
+ { name: "옵션", get description() { return t("slash.options"); }, source: "web" },
16
+ { name: "read", get description() { return t("slash.read"); }, source: "web" },
17
+ { name: "기기", get description() { return t("slash.devices"); }, source: "web" },
18
+ { name: "기기 삭제", get description() { return t("slash.deviceDelete"); }, source: "web" },
14
19
  ];
15
20
 
16
- export const SOURCE_LABELS = { skill: "스킬", extension: "명령", prompt: "템플릿", web: "웹 지그" };
21
+ export const SOURCE_LABELS = {
22
+ get skill() { return t("slash.skill"); },
23
+ get extension() { return t("slash.command"); },
24
+ get prompt() { return t("slash.template"); },
25
+ get web() { return t("slash.webZig"); },
26
+ };
17
27
  const SOURCE_ORDER = ["skill", "extension", "prompt", "web"];
18
28
 
19
29
  export function slashQuery(text) {