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

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 (143) hide show
  1. package/README.ko.md +36 -4
  2. package/README.md +36 -4
  3. package/assets/agent/gateway-rules.md +29 -19
  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 +60 -11
  8. package/docs/manual.md +61 -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 +97 -13
  29. package/src/approvals.ts +6 -1
  30. package/src/cli/doctor.ts +7 -0
  31. package/src/cli/main.ts +38 -0
  32. package/src/cli/messages.ts +10 -2
  33. package/src/cli/rotate-main.ts +2 -1
  34. package/src/cli/rules.ts +70 -0
  35. package/src/cli/scan.ts +414 -0
  36. package/src/cli/security-import.ts +155 -0
  37. package/src/compact.ts +22 -0
  38. package/src/decisions.ts +6 -4
  39. package/src/engine/compat.json +3 -3
  40. package/src/engine/index.ts +1 -0
  41. package/src/engine/schema-guard.ts +12 -4
  42. package/src/engine/sdk.ts +8 -0
  43. package/src/formal-verification.ts +366 -0
  44. package/src/gc.ts +37 -6
  45. package/src/hub.ts +339 -79
  46. package/src/main.ts +95 -9
  47. package/src/maintenance.ts +1 -1
  48. package/src/manual.ts +9 -2
  49. package/src/profile-prompt.ts +73 -0
  50. package/src/profile.ts +33 -10
  51. package/src/protocol.ts +11 -2
  52. package/src/question-notifications.ts +4 -1
  53. package/src/reply-notifications.ts +7 -1
  54. package/src/repo-security-setting.ts +33 -0
  55. package/src/runtime/paths.ts +16 -0
  56. package/src/security/baseline.ts +172 -0
  57. package/src/security/dex-strings.ts +242 -0
  58. package/src/security/repo-scan.ts +369 -0
  59. package/src/security/revision-probe.ts +144 -0
  60. package/src/security/terms.ts +97 -0
  61. package/src/security-allow.ts +1 -0
  62. package/src/security-baseline.ts +113 -0
  63. package/src/security-check.ts +91 -16
  64. package/src/server-text.ts +38 -0
  65. package/src/store.ts +142 -16
  66. package/src/transcript.ts +7 -1
  67. package/src/ui-language.ts +33 -0
  68. package/src/update/check.ts +7 -3
  69. package/src/update/handoff-system.ts +24 -16
  70. package/src/update/handoff.ts +23 -12
  71. package/src/update/helper.ts +72 -36
  72. package/src/update/request.ts +46 -30
  73. package/src/update/state.ts +21 -11
  74. package/src/update/strategy.ts +12 -5
  75. package/src/update/target.ts +32 -15
  76. package/src/usage.ts +43 -20
  77. package/src/version.ts +39 -16
  78. package/src/voice/agents.ts +8 -3
  79. package/src/voice/install.ts +29 -18
  80. package/src/work-watch.ts +40 -18
  81. package/web/agent-icon.js +15 -9
  82. package/web/agents.d.ts +201 -0
  83. package/web/agents.js +813 -0
  84. package/web/app-download.js +32 -19
  85. package/web/app.js +1407 -278
  86. package/web/autocopy.js +6 -1
  87. package/web/boot.js +27 -8
  88. package/web/commands.js +20 -11
  89. package/web/decisions.js +70 -49
  90. package/web/devlog.js +25 -19
  91. package/web/diagram.js +8 -3
  92. package/web/enroll.js +43 -22
  93. package/web/gc.js +32 -19
  94. package/web/i18n/agent-icon.js +24 -0
  95. package/web/i18n/agents.js +48 -0
  96. package/web/i18n/app-download.js +26 -0
  97. package/web/i18n/autocopy.js +5 -0
  98. package/web/i18n/boot.js +24 -0
  99. package/web/i18n/commands.js +34 -0
  100. package/web/i18n/decisions.js +24 -0
  101. package/web/i18n/devlog.js +60 -0
  102. package/web/i18n/diagram.js +12 -0
  103. package/web/i18n/enroll.js +22 -0
  104. package/web/i18n/gc.js +38 -0
  105. package/web/i18n/lastsent.js +10 -0
  106. package/web/i18n/main.d.ts +4 -0
  107. package/web/i18n/main.js +527 -0
  108. package/web/i18n/manual.d.ts +4 -0
  109. package/web/i18n/manual.js +14 -0
  110. package/web/i18n/memo.js +30 -0
  111. package/web/i18n/notifications.js +16 -0
  112. package/web/i18n/profile.js +13 -0
  113. package/web/i18n/reference.js +26 -0
  114. package/web/i18n/security-inbox.js +40 -0
  115. package/web/i18n/slash.js +38 -0
  116. package/web/i18n/turn-stats.js +5 -0
  117. package/web/i18n/update.js +42 -0
  118. package/web/i18n/voice.js +28 -0
  119. package/web/i18n/wake.js +16 -0
  120. package/web/i18n/workid.js +10 -0
  121. package/web/i18n/workstatus.js +20 -0
  122. package/web/i18n.d.ts +20 -0
  123. package/web/i18n.js +63 -0
  124. package/web/index.html +156 -96
  125. package/web/lastsent.js +9 -4
  126. package/web/manual.d.ts +1 -1
  127. package/web/manual.js +11 -5
  128. package/web/memo.js +18 -13
  129. package/web/notifications.js +12 -7
  130. package/web/profile.js +42 -17
  131. package/web/reference.js +20 -15
  132. package/web/security-inbox.d.ts +18 -1
  133. package/web/security-inbox.js +173 -4
  134. package/web/slash.js +23 -13
  135. package/web/style.css +132 -3
  136. package/web/tooltip.js +5 -2
  137. package/web/turn-stats.js +6 -2
  138. package/web/update.js +31 -23
  139. package/web/voice.js +18 -8
  140. package/web/wake.d.ts +4 -0
  141. package/web/wake.js +7 -3
  142. package/web/workid.js +12 -2
  143. package/web/workstatus.js +36 -32
package/src/main.ts CHANGED
@@ -1,10 +1,15 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join, normalize } from "node:path";
4
4
  import type { Server, ServerWebSocket, WebSocketHandler } from "bun";
5
5
  import { createSttProvider } from "./adapters/voice/stt.ts";
6
6
  import { createTtsProvider, speakableText, splitForTts } from "./adapters/voice/tts.ts";
7
- import { handleHeld, handleSecurityAllowedValues, handleSecurityCheckSetting, MessageGuard, SECURITY_CHECK_PATH, SECURITY_HELD_PATH, SECURITY_ALLOWED_PATH, SecurityCheckError, defaultChecker } from "./security-check.ts";
7
+ import { handleHeld, handleSecurityAllowedValues, handleSecurityBaseline, handleSecurityCheckSetting, MessageGuard, SECURITY_BASELINE_PATH, SECURITY_CHECK_PATH, SECURITY_HELD_PATH, SECURITY_ALLOWED_PATH, SecurityCheckError, defaultChecker } from "./security-check.ts";
8
+ import { defaultFormalDeps, FormalVerification, handleFormalVerification } from "./formal-verification.ts";
9
+ import { handleRepoSecurityCheckSetting, REPO_SECURITY_CHECK_PATH } from "./repo-security-setting.ts";
10
+ import { handleUiLanguage, readUiLanguage, UI_LANGUAGE_EVENT, UI_LANGUAGE_PATH } from "./ui-language.ts";
11
+ import { say, setServerLanguage } from "./server-text.ts";
12
+ import { SecurityBaseline } from "./security-baseline.ts";
8
13
  import { handleVoiceInstall, handleVoiceSetting, routeVoiceApi, VOICE_INSTALL_PATH, VOICE_SETTING_PATH, voiceGate, voiceUpdated } from "./voice-api.ts";
9
14
  import { readVoiceSetting, VoiceSetting } from "./voice-setting.ts";
10
15
  import { applyVoiceAgents } from "./voice/agents.ts";
@@ -65,13 +70,18 @@ import { engine } from "./engine/index.ts";
65
70
  import { manifestOf } from "./engine/install.ts";
66
71
  import { launchUpdater, systemContext, systemDeps } from "./cli/launchd.ts";
67
72
  import { acceptUpdateConsent, requestUpdate, type UpdateApiDeps } from "./update/request.ts";
68
- import { PHASE_LABELS, readState, UPDATE_CONSENT_PATH, UPDATE_PATH, UPDATE_STATE_PATH, updateDir, UpdateWatcher } from "./update/state.ts";
73
+ import { phaseLabel, readState, UPDATE_CONSENT_PATH, UPDATE_PATH, UPDATE_STATE_PATH, updateDir, UpdateWatcher } from "./update/state.ts";
69
74
  import { resolveTarget, tarEntry } from "./update/target.ts";
70
75
  import { registryUrl, UPDATE_CHECK_PATH, UPDATE_STATUS_PATH, UpdateChecker, versionAvailableEnvelope } from "./update/check.ts";
71
76
  import { MANUAL_PATH, serveManual } from "./manual.ts";
72
77
  import { clean as gcClean, GC_CLEAN_PATH, GC_PATH, type GcRoots, parseSelection as parseGcSelection, report as gcReport, systemGcDeps } from "./gc.ts";
78
+ import { mergeAgentDefinition, parseAgentDefinition, AgentDefinitionError } from "./agent/definition.ts";
79
+ import { AgentApi } from "./agent/api.ts";
80
+ import { AgentFileStore } from "./agent/files.ts";
81
+ import { agentList } from "./agent/list.ts";
73
82
 
74
83
  // Exit 0 so launchd (KeepAlive SuccessfulExit=false) does not restart a gateway that has no consent.
84
+ setServerLanguage(() => readUiLanguage(runtimeHome()));
75
85
  const consentRefusal = startRefusal(runtimeHome(), installKind(), () => loadNotice());
76
86
  if (consentRefusal !== null) {
77
87
  console.error(`${new Date().toISOString()} ${consentRefusal}`);
@@ -83,11 +93,16 @@ const runtime = runtimePaths();
83
93
  const schemaRun = runtimePaths().run;
84
94
  const gcHome = runtimePaths();
85
95
  const gcRoots: GcRoots = { kind: installKind(), home: gcHome.home, backups: join(config.dataDir, "backups"), omo: gcHome.omo,
86
- update: join(gcHome.home, "update"), tmp: process.env.TMPDIR || tmpdir() };
96
+ update: join(gcHome.home, "update"), tmp: process.env.TMPDIR || tmpdir(), agents: gcHome.agents };
87
97
  const gcDeps = systemGcDeps(join(import.meta.dir, ".."), { ...process.env, ZIGMINI_HOME: gcHome.home });
88
98
  const store = new Store(config.dataDir);
89
99
  const GATEWAY_ID = gatewayId(store);
90
100
  const GATEWAY_VERSION = (JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }).version;
101
+ const securityBaseline = new SecurityBaseline(store, { secrets: runtime.secrets, gatewayVersion: GATEWAY_VERSION });
102
+ // Improvement 250: formal verification switches; installs run in the gateway process and report through this event.
103
+ const formalVerification = new FormalVerification(runtime.formalVerificationFile, runtime.formalToolsDir, defaultFormalDeps(), (state) => publish({
104
+ v: PROTOCOL_VERSION, id: crypto.randomUUID(), ts: new Date().toISOString(), conv: "*", type: "formal-verification.updated", data: state,
105
+ }));
91
106
  const exchangeLimiter = new RateLimiter(EXCHANGE_LIMIT, EXCHANGE_WINDOW_MS);
92
107
  const apkSource = new ApkSource(APK_DIR);
93
108
  const downloadLinks = new DownloadLinks(Date.now);
@@ -154,12 +169,16 @@ let securityDecisionRefresh = () => {};
154
169
  const securityEvent = (type: string, data: unknown) => publish({
155
170
  v: PROTOCOL_VERSION, id: crypto.randomUUID(), ts: new Date().toISOString(), conv: "*", type, data,
156
171
  });
172
+ const securityAllowedUpdated = (revision: number) => {
173
+ securityBaseline.export();
174
+ securityEvent("security-check.allowed-values.updated", { revision });
175
+ };
157
176
  const messageGuard = new MessageGuard(defaultChecker(), runtimePaths().securityCheckFile, (held) => publish({
158
177
  v: PROTOCOL_VERSION, id: crypto.randomUUID(), ts: new Date().toISOString(), conv: held.conv, type: "security-check.held", data: held,
159
178
  }), { store, state: (state) => {
160
179
  securityEvent("security-check.state", state);
161
180
  if (state.status === "sent" || state.status === "cancelled") securityEvent("security-check.resolved", { id: state.id, outcome: state.status === "sent" ? "sent" : "dropped" });
162
- }, allowedUpdated: (revision) => securityEvent("security-check.allowed-values.updated", { revision }),
181
+ }, allowedUpdated: securityAllowedUpdated,
163
182
  decisionUpdated: () => securityDecisionRefresh() });
164
183
  const hub = new Hub(
165
184
  store,
@@ -185,6 +204,8 @@ const hub = new Hub(
185
204
  },
186
205
  (e) => publish(e),
187
206
  );
207
+ const agentFiles = new AgentFileStore();
208
+ const agentApi = new AgentApi(agentFiles, store, hub);
188
209
  const watcher = new WorkWatcher(store, {
189
210
  requestStatus: (workId, requestKey, instructions) => {
190
211
  work.requestStatus(workId, requestKey, instructions);
@@ -513,7 +534,7 @@ async function handleClient(ws: ServerWebSocket<Conn>, raw: string): Promise<voi
513
534
  return;
514
535
  case "conversation.rotate":
515
536
  await hub.rotateSession(msg.conv, "manual").catch((e) => {
516
- const message = e instanceof Error && e.message === "turn_running" ? ROTATION_HELD_MESSAGE : String(e);
537
+ const message = e instanceof Error && e.message === "turn_running" ? say(ROTATION_HELD_MESSAGE) : String(e);
517
538
  ws.send(envelope("error", { message }, msg.conv));
518
539
  });
519
540
  return;
@@ -580,9 +601,51 @@ function deviceAdmin(req: Request, url: URL): Response {
580
601
 
581
602
  async function handle(req: Request, srv: Server<Conn>, trusted: boolean): Promise<Response | undefined> {
582
603
  const url = new URL(req.url);
604
+ if (url.pathname === "/api/agents" || url.pathname.startsWith("/api/agents/")) {
605
+ return agentApi.handle(req, trusted);
606
+ }
583
607
  if (url.pathname === "/internal/maintenance") return maintenance.handle(req, trusted);
584
608
  if (url.pathname === "/internal/runtime") return runtimeApi(req, trusted);
585
609
  if (url.pathname === "/internal/work") return hub.admitWorkRequest(() => work.handleHttp(req, trusted, omoToken));
610
+ if (url.pathname === "/internal/agent-list") {
611
+ const denied = maintenanceAuth(req, trusted, omoToken);
612
+ if (denied) return denied;
613
+ if (req.method !== "GET") return Response.json({ error: "method_not_allowed" }, { status: 405 });
614
+ return Response.json(agentList(agentFiles.list(), store.listConversations(false)));
615
+ }
616
+ if (url.pathname === "/internal/agent-history") {
617
+ const denied = maintenanceAuth(req, trusted, omoToken);
618
+ if (denied) return denied;
619
+ if (req.method !== "GET") return Response.json({ error: "method_not_allowed" }, { status: 405 });
620
+ const agent = url.searchParams.get("agent") ?? "";
621
+ const query = url.searchParams.get("query") ?? "";
622
+ if (!/^[a-zA-Z0-9_-]+$/.test(agent)) return Response.json({ error: "invalid_agent_id" }, { status: 400 });
623
+ if (query === "" || query.length > 200) return Response.json({ error: "invalid_query" }, { status: 400 });
624
+ const requested = Number(url.searchParams.get("limit") ?? "20");
625
+ const limit = Number.isInteger(requested) ? Math.min(Math.max(requested, 1), 50) : 20;
626
+ return Response.json({ hits: store.searchAgentHistory(agent, query, limit) });
627
+ }
628
+ if (url.pathname.startsWith("/internal/agent/")) {
629
+ const denied = maintenanceAuth(req, trusted, omoToken);
630
+ if (denied) return denied;
631
+ if (req.method !== "GET") return Response.json({ error: "method_not_allowed" }, { status: 405 });
632
+ const id = url.pathname.slice("/internal/agent/".length);
633
+ if (!/^[a-zA-Z0-9_-]+$/.test(id)) return Response.json({ error: "invalid_agent_id" }, { status: 400 });
634
+ try {
635
+ const root = realpathSync(join(runtimeHome(), "agents"));
636
+ const file = realpathSync(join(root, id, "agent.md"));
637
+ if (!file.startsWith(`${root}/${id}/`)) return Response.json({ error: "agent_path_outside_root" }, { status: 400 });
638
+ const agent = parseAgentDefinition(readFileSync(file, "utf8"));
639
+ const defaultsPath = join(root, "defaults.md");
640
+ if (existsSync(defaultsPath) && realpathSync(defaultsPath) !== defaultsPath) return Response.json({ error: "agent_defaults_symlink" }, { status: 400 });
641
+ const defaults = existsSync(defaultsPath) ? parseAgentDefinition(readFileSync(defaultsPath, "utf8"), "defaults") : undefined;
642
+ return Response.json(mergeAgentDefinition(agent, defaults));
643
+ } catch (error) {
644
+ if (error instanceof AgentDefinitionError) return Response.json({ error: "invalid_agent_definition", field: error.field }, { status: 400 });
645
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return Response.json({ error: "agent_not_found" }, { status: 404 });
646
+ throw error;
647
+ }
648
+ }
586
649
  if (url.pathname === "/internal/omo-check") return hub.admitWorkRequest(() => omoCheck.handle(req, trusted));
587
650
  let deviceId: string | null = null;
588
651
  if (trusted) {
@@ -666,8 +729,30 @@ async function handle(req: Request, srv: Server<Conn>, trusted: boolean): Promis
666
729
  const response = await handleHeld(req, url, messageGuard, () => {}, { client: trusted ? "web" : "android", deviceId });
667
730
  if (response) return response;
668
731
  }
732
+ if (url.pathname === SECURITY_BASELINE_PATH || url.pathname.startsWith(`${SECURITY_BASELINE_PATH}/`) ||
733
+ (req.method === "PATCH" && url.pathname.startsWith(`${SECURITY_ALLOWED_PATH}/`))) {
734
+ const response = await handleSecurityBaseline(req, url, trusted, store, securityBaseline,
735
+ (revision) => securityEvent("security-check.allowed-values.updated", { revision }));
736
+ if (response) return response;
737
+ }
669
738
  if (url.pathname === SECURITY_ALLOWED_PATH || url.pathname.startsWith(`${SECURITY_ALLOWED_PATH}/`)) {
670
- const response = await handleSecurityAllowedValues(req, url, store, (revision) => securityEvent("security-check.allowed-values.updated", { revision }));
739
+ const response = await handleSecurityAllowedValues(req, url, store, securityAllowedUpdated);
740
+ if (response) return response;
741
+ }
742
+ if (url.pathname === REPO_SECURITY_CHECK_PATH) {
743
+ const response = await handleRepoSecurityCheckSetting(req, trusted, runtime.repoSecurityCheckFile, (enabled) => publish({
744
+ v: PROTOCOL_VERSION, id: crypto.randomUUID(), ts: new Date().toISOString(), conv: "*", type: "repo-security-check.updated", data: { enabled },
745
+ }));
746
+ if (response) return response;
747
+ }
748
+ {
749
+ const response = await handleFormalVerification(req, url, trusted, formalVerification);
750
+ if (response) return response;
751
+ }
752
+ if (url.pathname === UI_LANGUAGE_PATH) {
753
+ const response = await handleUiLanguage(req, runtimeHome(), (language) => publish({
754
+ v: PROTOCOL_VERSION, id: crypto.randomUUID(), ts: new Date().toISOString(), conv: "*", type: UI_LANGUAGE_EVENT, data: { language },
755
+ }));
671
756
  if (response) return response;
672
757
  }
673
758
  if (url.pathname === SECURITY_CHECK_PATH) {
@@ -688,13 +773,13 @@ async function handle(req: Request, srv: Server<Conn>, trusted: boolean): Promis
688
773
  return Response.json(answer.body, { status: answer.status });
689
774
  }
690
775
  if (url.pathname === UPDATE_CONSENT_PATH && req.method === "POST") {
691
- if (!trusted) return Response.json({ ok: false, code: "forbidden", message: "권한 동의는 맥에서만 받습니다" }, { status: 403 });
776
+ if (!trusted) return Response.json({ ok: false, code: "forbidden", message: say({ en: "Permission consent is only accepted on the Mac", ko: "권한 동의는 맥에서만 받습니다" }) }, { status: 403 });
692
777
  const answer = await acceptUpdateConsent(await req.json().catch(() => null), updateApi);
693
778
  return Response.json(answer.body, { status: answer.status });
694
779
  }
695
780
  if (url.pathname === UPDATE_STATE_PATH && req.method === "GET") {
696
781
  const state = readState(runtime.home);
697
- return Response.json(state === null ? null : { ...state, label: PHASE_LABELS[state.phase] });
782
+ return Response.json(state === null ? null : { ...state, label: phaseLabel(state.phase) });
698
783
  }
699
784
  // Reserve before parsing bodies: decisions, voice and auxiliary workers can
700
785
  // otherwise cross native admission after a maintenance check.
@@ -746,6 +831,7 @@ async function routeApi(req: Request, url: URL): Promise<Response> {
746
831
  return Response.json({
747
832
  ok: true,
748
833
  omo: omoHealth,
834
+ security: securityBaseline.status(),
749
835
  ...(omoHealth === SCHEMA_TOO_NEW ? { schemaGuard: schema?.message } : {}),
750
836
  stt: stt.name,
751
837
  tts: tts.name,
@@ -186,7 +186,7 @@ export async function nativeMaintenanceBlocker(
186
186
  row.kind !== "interactive" ||
187
187
  owners.get(row.sessionId) !== row.sessionPath ||
188
188
  !record(row.context) ||
189
- row.context.zgw_role !== "main"
189
+ (row.context.zgw_role !== "main" && row.context.zgw_role !== "agent")
190
190
  ) {
191
191
  return "native_owner_unverified";
192
192
  }
package/src/manual.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { join } from "node:path";
2
+ import { say, type Text } from "./server-text.ts";
2
3
 
3
4
  /**
4
5
  * The user manual (docs/manual.md, docs/manual.ko.md) for the web 설정 "매뉴얼" button and the app's work status screen.
@@ -8,6 +9,12 @@ export const MANUAL_PATH = "/api/manual";
8
9
  export const MANUAL_DIR = join(import.meta.dir, "..", "docs");
9
10
  export type ManualLang = "ko" | "en";
10
11
 
12
+ const UNKNOWN_LANG: Text = { en: "lang must be ko or en.", ko: "lang은 ko 또는 en입니다." };
13
+ const MANUAL_MISSING: Text = {
14
+ en: "This installation has no manual file. Update the gateway.",
15
+ ko: "이 설치에는 매뉴얼 파일이 없습니다. 게이트웨이를 업데이트하세요.",
16
+ };
17
+
11
18
  /** The `lang` query value as a language: missing or empty is Korean; anything but `ko` or `en` is null. */
12
19
  export function manualLang(value: string | null): ManualLang | null {
13
20
  if (value === null || value === "" || value === "ko") return "ko";
@@ -22,10 +29,10 @@ export function manualFile(lang: ManualLang): string {
22
29
  export async function serveManual(req: Request, url: URL, dir: string = MANUAL_DIR): Promise<Response> {
23
30
  if (req.method !== "GET") return Response.json({ error: "method_not_allowed" }, { status: 405 });
24
31
  const lang = manualLang(url.searchParams.get("lang"));
25
- if (lang === null) return Response.json({ error: "unknown_lang", message: "lang은 ko 또는 en입니다." }, { status: 400 });
32
+ if (lang === null) return Response.json({ error: "unknown_lang", message: say(UNKNOWN_LANG) }, { status: 400 });
26
33
  const file = Bun.file(join(dir, manualFile(lang)));
27
34
  if (!(await file.exists())) {
28
- return Response.json({ error: "manual_missing", message: "이 설치에는 매뉴얼 파일이 없습니다. 게이트웨이를 업데이트하세요." }, { status: 404 });
35
+ return Response.json({ error: "manual_missing", message: say(MANUAL_MISSING) }, { status: 404 });
29
36
  }
30
37
  return new Response(file, { headers: { "content-type": "text/markdown; charset=utf-8", "cache-control": "no-cache", "content-language": lang } });
31
38
  }
@@ -0,0 +1,73 @@
1
+ import { NICKNAME_DEFAULT, type Profile } from "./profile.ts";
2
+ import { isTextOf } from "./server-text.ts";
3
+ import { readFileSync } from "node:fs";
4
+ import { runtimePaths } from "./runtime/paths.ts";
5
+
6
+ export type PromptProfile = {
7
+ readonly nickname: string | undefined;
8
+ readonly agentName: string;
9
+ };
10
+ export const PROFILE_TIMEOUT_MS = 1_000;
11
+
12
+ /** Read once per turn; a failed request keeps this session's last known profile. */
13
+ export async function readPromptProfile(fallback: PromptProfile): Promise<PromptProfile> {
14
+ try {
15
+ const base = process.env.ZGW_WORK_URL ?? `http://127.0.0.1:${process.env.ZGW_PORT ?? "8700"}`;
16
+ const response = await fetch(new URL("/api/profile", base), { signal: AbortSignal.timeout(PROFILE_TIMEOUT_MS) });
17
+ if (!response.ok) throw new Error("profile_http");
18
+ const data: unknown = await response.json();
19
+ const profile = parseProfile(data);
20
+ if (!profile) throw new Error("profile_invalid");
21
+ return profile;
22
+ } catch { // This HTTP boundary deliberately handles unavailable or invalid gateway responses.
23
+ console.warn("gateway profile: keeping last known values");
24
+ return fallback;
25
+ }
26
+ }
27
+
28
+ function parseProfile(data: unknown): Profile | undefined {
29
+ if (typeof data !== "object" || data === null || !("nickname" in data) || !("agentName" in data)) return undefined;
30
+ if (typeof data.nickname !== "string" || typeof data.agentName !== "string") return undefined;
31
+ const agentName = data.agentName.replace(/\s+/g, " ").trim();
32
+ if (!agentName) return undefined;
33
+ return { nickname: data.nickname, agentName };
34
+ }
35
+
36
+ /** Profile data is quoted and kept on one line, independently of rule-file toggles. */
37
+ export function nicknamePrompt(nickname: string | undefined): string {
38
+ if (nickname === undefined) return "The user's gateway profile nickname is unavailable.";
39
+ const name = promptName(nickname);
40
+ if (!name || isTextOf(name, NICKNAME_DEFAULT)) return "The user has not set a gateway profile nickname. Do not use the built-in default as their name.";
41
+ return `The user's gateway profile nickname is ${JSON.stringify(name)}. Address the user by it as the rules say.`;
42
+ }
43
+
44
+ function promptName(name: string): string {
45
+ return name.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, "").trim();
46
+ }
47
+
48
+ /** Only the name refreshes; the agent's definition, tools and memory keep their session snapshots. */
49
+ export async function readAgentPromptName(id: string, fallback: string): Promise<string> {
50
+ try {
51
+ const token = readFileSync(runtimePaths().omoTokenFile, "utf8").trim();
52
+ const base = process.env.ZGW_WORK_URL ?? `http://127.0.0.1:${process.env.ZGW_PORT ?? "8700"}`;
53
+ const response = await fetch(new URL(`/internal/agent/${encodeURIComponent(id)}`, base), {
54
+ headers: { authorization: `Bearer ${token}` }, redirect: "error", signal: AbortSignal.timeout(5_000),
55
+ });
56
+ if (!response.ok) throw new Error("agent_profile_http");
57
+ const data: unknown = await response.json();
58
+ if (typeof data !== "object" || data === null || !("fields" in data) ||
59
+ typeof data.fields !== "object" || data.fields === null || !("name" in data.fields) ||
60
+ typeof data.fields.name !== "string") throw new Error("agent_profile_invalid");
61
+ const name = promptName(data.fields.name);
62
+ if (!name) throw new Error("agent_profile_name_missing");
63
+ return name;
64
+ } catch { // This HTTP boundary retains the validated session-start name on refresh failure.
65
+ console.warn("agent profile: keeping last known name");
66
+ return fallback;
67
+ }
68
+ }
69
+
70
+ export function agentNamePrompt(name: string): string {
71
+ const quoted = JSON.stringify(promptName(name));
72
+ return `Your name is ${quoted}. The user calls you ${quoted}.`;
73
+ }
package/src/profile.ts CHANGED
@@ -1,13 +1,15 @@
1
1
  // The owner's profile nickname and the agent's (gateway main) name, kept in the gateway settings so every web browser,
2
2
  // the phone and the main session see the same names. The web (web/profile.js) applies the same rules for its local cache.
3
+ import { isTextOf, say, type Text } from "./server-text.ts";
3
4
  import type { Store } from "./store.ts";
4
5
 
5
6
  export const NICKNAME_SETTING = "profile.nickname";
6
- export const DEFAULT_NICKNAME = "나";
7
+ /** Built-in default names. They follow the saved UI language; a stored built-in default of either language reads as the current one. */
8
+ export const NICKNAME_DEFAULT: Text = { en: "Me", ko: "나" };
7
9
  export const NICKNAME_MAX = 20;
8
10
 
9
11
  export const AGENT_NAME_SETTING = "profile.agentName";
10
- export const DEFAULT_AGENT_NAME = "지그";
12
+ export const AGENT_NAME_DEFAULT: Text = { en: "Zig", ko: "지그" };
11
13
  /** Display-width units: Hangul, CJK and full-width characters count 2, ASCII and half-width characters 1. */
12
14
  export const AGENT_NAME_WIDTH = 20;
13
15
 
@@ -19,10 +21,31 @@ export interface Profile {
19
21
  export type ProfileError = "nickname_required" | "agent_name_required" | "agent_name_too_long";
20
22
  export type ProfileSave = { readonly profile: Profile } | { readonly error: ProfileError };
21
23
 
22
- /** Whitespace collapsed and trimmed, at most NICKNAME_MAX characters; blank becomes the default. */
23
- export function normalizeNickname(value: string): string {
24
+ /** The nickname default in the current UI language. */
25
+ export function defaultNickname(): string {
26
+ return say(NICKNAME_DEFAULT);
27
+ }
28
+
29
+ /** The agent name default in the current UI language. */
30
+ export function defaultAgentName(): string {
31
+ return say(AGENT_NAME_DEFAULT);
32
+ }
33
+
34
+ /** The user's own nickname: whitespace collapsed and trimmed, at most NICKNAME_MAX characters; "" for blank or a built-in default. */
35
+ function ownNickname(value: string): string {
36
+ const name = [...value.replace(/\s+/g, " ").trim()].slice(0, NICKNAME_MAX).join("");
37
+ return isTextOf(name, NICKNAME_DEFAULT) ? "" : name;
38
+ }
39
+
40
+ /** The user's own agent name: whitespace collapsed and trimmed; "" for blank or a built-in default. */
41
+ function ownAgentName(value: string): string {
24
42
  const name = value.replace(/\s+/g, " ").trim();
25
- return name ? [...name].slice(0, NICKNAME_MAX).join("") : DEFAULT_NICKNAME;
43
+ return isTextOf(name, AGENT_NAME_DEFAULT) ? "" : name;
44
+ }
45
+
46
+ /** The nickname to show: the user's own, else the current language's default. */
47
+ export function normalizeNickname(value: string): string {
48
+ return ownNickname(value) || defaultNickname();
26
49
  }
27
50
 
28
51
  /** True for a code point shown two columns wide: Hangul, CJK, full-width forms and wide emoji. */
@@ -41,12 +64,12 @@ export function displayWidth(text: string): number {
41
64
  }
42
65
 
43
66
  export function normalizeAgentName(value: string): string {
44
- return value.replace(/\s+/g, " ").trim() || DEFAULT_AGENT_NAME;
67
+ return ownAgentName(value) || defaultAgentName();
45
68
  }
46
69
 
47
70
  function storedAgentName(value: string | null): string {
48
- const name = normalizeAgentName(value ?? "");
49
- return displayWidth(name) <= AGENT_NAME_WIDTH ? name : DEFAULT_AGENT_NAME;
71
+ const name = ownAgentName(value ?? "");
72
+ return name && displayWidth(name) <= AGENT_NAME_WIDTH ? name : defaultAgentName();
50
73
  }
51
74
 
52
75
  export function readProfile(store: Pick<Store, "getSetting">): Profile {
@@ -66,9 +89,9 @@ export function saveProfile(store: Pick<Store, "getSetting" | "setSetting">, bod
66
89
  if (nickname === undefined && agentName === undefined) return { error: "nickname_required" };
67
90
  if (nickname !== undefined && typeof nickname !== "string") return { error: "nickname_required" };
68
91
  if (agentName !== undefined && typeof agentName !== "string") return { error: "agent_name_required" };
69
- const name = agentName === undefined ? undefined : normalizeAgentName(agentName);
92
+ const name = agentName === undefined ? undefined : ownAgentName(agentName);
70
93
  if (name !== undefined && displayWidth(name) > AGENT_NAME_WIDTH) return { error: "agent_name_too_long" };
71
- if (nickname !== undefined) store.setSetting(NICKNAME_SETTING, normalizeNickname(nickname));
94
+ if (nickname !== undefined) store.setSetting(NICKNAME_SETTING, ownNickname(nickname));
72
95
  if (name !== undefined) store.setSetting(AGENT_NAME_SETTING, name);
73
96
  return { profile: readProfile(store) };
74
97
  }
package/src/protocol.ts CHANGED
@@ -1,7 +1,15 @@
1
+ import type { Text } from "./server-text.ts";
2
+
1
3
  export const PROTOCOL_VERSION = 1;
2
4
 
3
- /** The `error` message a refused `conversation.rotate` gets when a turn or an open wait holds the session (hub `turn_running`). */
4
- export const ROTATION_HELD_MESSAGE = "진행 중이거나 확인되지 않은 작업이 있어 같은 세션을 유지합니다.";
5
+ /**
6
+ * The `error` message a refused `conversation.rotate` gets when a turn or an open wait holds the session (hub `turn_running`).
7
+ * The sender picks the language with say(); a reader compares with isTextOf() so both languages are recognized.
8
+ */
9
+ export const ROTATION_HELD_MESSAGE: Text = {
10
+ en: "The same session is kept because some work is in progress or not yet confirmed.",
11
+ ko: "진행 중이거나 확인되지 않은 작업이 있어 같은 세션을 유지합니다.",
12
+ };
5
13
 
6
14
  export type Channel = "web" | "app" | "home" | "hud" | "discord";
7
15
  export type Trust = "owner" | "owner-voice" | "unknown";
@@ -51,6 +59,7 @@ export interface ConversationInfo {
51
59
  title: string;
52
60
  threadId: string | null;
53
61
  createdAt: string;
62
+ readonly agentId: string | null;
54
63
  }
55
64
 
56
65
  export interface ConversationActivity {
@@ -1,4 +1,7 @@
1
1
  import type { Envelope } from "./protocol.ts";
2
+ import { say, type Text } from "./server-text.ts";
3
+
4
+ const DEFAULT_TITLE: Text = { en: "An answer is needed", ko: "답변이 필요합니다" };
2
5
 
3
6
  export interface QuestionNotification {
4
7
  readonly id: string;
@@ -35,7 +38,7 @@ function questionText(data: Record<string, unknown>, fallback: string): string {
35
38
  function notificationOf(data: unknown, envelopeConv = "*"): QuestionNotification | null {
36
39
  if (!isRecord(data) || data.method !== "question" || typeof data.actionId !== "string" || !data.actionId) return null;
37
40
  const conv = typeof data.conv === "string" && data.conv ? data.conv : envelopeConv;
38
- const title = typeof data.title === "string" && data.title.trim() ? data.title.trim() : "답변이 필요합니다";
41
+ const title = typeof data.title === "string" && data.title.trim() ? data.title.trim() : say(DEFAULT_TITLE);
39
42
  return {
40
43
  id: `question:${data.actionId}`,
41
44
  conv,
@@ -64,10 +64,16 @@ export class ReplyNotifications<Connection extends object> {
64
64
  /** The read's revision is captured before RPC, so a toggle cannot admit an older in-flight read. */
65
65
  observe(source: NativeReplySource, entries: readonly NativeReplyEntry[], revision: string): void {
66
66
  if (revision !== this.revision()) return;
67
+ const conv = this.store.getConversation(source.conv);
68
+ const applied = conv?.agentId
69
+ ? this.store.getEvent(this.store.lastSeq(source.conv, "agent.applied")) : null;
70
+ const agentNotify = !conv?.agentId || (applied !== null &&
71
+ applied.seq > this.store.lastSeq(source.conv, "session.rotated") &&
72
+ typeof applied.data === "object" && applied.data !== null && "notify" in applied.data && applied.data.notify === true);
67
73
  for (const entry of entries) {
68
74
  if (typeof entry.id !== "string" || !entry.id) throw new Error("reply_history_entry_without_id");
69
75
  const text = replyText(entry);
70
- const notification = this.settings().mode === "all-replies" && text !== null ? {
76
+ const notification = this.settings().mode === "all-replies" && agentNotify && text !== null ? {
71
77
  id: crypto.randomUUID(), conv: source.conv, title: source.title, text,
72
78
  ...(source.workId ? { workId: source.workId } : {}),
73
79
  } : null;
@@ -0,0 +1,33 @@
1
+ import { readVoiceSetting, writeVoiceSetting } from "./voice-setting.ts";
2
+ import type { Language } from "./cli/messages.ts";
3
+
4
+ /**
5
+ * Improvement 250: the output check switch ("결과물 검사", the repository security check), separate from the check of
6
+ * messages to the engine (security-check.ts). On (the default) `zigmini scan` runs and the package agent rules tell
7
+ * sessions to run it before a merge. Off makes `zigmini scan` stop with SCAN_DISABLED_EXIT and removes that rule from
8
+ * new sessions. This repository's test:all security checks use the scan library directly and do not read this switch:
9
+ * the "check before a dev merge" duty is the operator's rule, not a gateway feature.
10
+ */
11
+ export const SCAN_DISABLED_EXIT = 4;
12
+ const DISABLED_TEXT = {
13
+ en: "Output check is off in the gateway settings; zigmini scan did not run (exit 4). Turn on \"Output check\" to scan.",
14
+ ko: "게이트웨이 설정에서 결과물 검사가 꺼져 있어 zigmini scan을 실행하지 않았습니다(종료 코드 4). 검사하려면 \"결과물 검사\"를 켜세요.",
15
+ } as const;
16
+
17
+ /** The notice when the switch is off, else null; --help still works while it is off. */
18
+ export function scanDisabledNotice(file: string, argv: readonly string[], lang: Language): string | null {
19
+ if (argv.includes("--help") || argv.includes("-h") || readVoiceSetting(file)) return null;
20
+ return DISABLED_TEXT[lang];
21
+ }
22
+ export const REPO_SECURITY_CHECK_PATH = "/api/settings/repo-security-check";
23
+
24
+ export async function handleRepoSecurityCheckSetting(req: Request, trusted: boolean, file: string, publish: (enabled: boolean) => void): Promise<Response | null> {
25
+ if (req.method === "GET") return Response.json({ enabled: readVoiceSetting(file) });
26
+ if (req.method !== "PUT") return null;
27
+ if (!trusted) return Response.json({ error: "forbidden" }, { status: 403 });
28
+ const body = (await req.json().catch(() => null)) as { enabled?: unknown } | null;
29
+ if (typeof body?.enabled !== "boolean") return Response.json({ error: "enabled_required" }, { status: 400 });
30
+ writeVoiceSetting(file, body.enabled);
31
+ publish(body.enabled);
32
+ return Response.json({ enabled: body.enabled });
33
+ }
@@ -34,6 +34,7 @@ export interface RuntimePorts {
34
34
 
35
35
  export interface RuntimePaths {
36
36
  readonly home: string;
37
+ readonly agents: string;
37
38
  readonly layout: Layout;
38
39
  readonly data: string;
39
40
  readonly transcripts: string;
@@ -42,6 +43,9 @@ export interface RuntimePaths {
42
43
  readonly scheduleFile: string;
43
44
  readonly voiceSettingFile: string;
44
45
  readonly securityCheckFile: string;
46
+ readonly repoSecurityCheckFile: string;
47
+ readonly formalVerificationFile: string;
48
+ readonly formalToolsDir: string;
45
49
  readonly logs: string;
46
50
  readonly secrets: string;
47
51
  readonly omoTokenFile: string;
@@ -102,6 +106,7 @@ export function runtimePaths(env: Env = process.env, exists: (path: string) => b
102
106
  const suffix = slotSuffix(slot);
103
107
  return {
104
108
  home,
109
+ agents: join(home, "agents"),
105
110
  layout,
106
111
  data,
107
112
  transcripts: value(env, "ZGW_TRANSCRIPT_DIR") ?? join(data, "transcripts"),
@@ -110,6 +115,9 @@ export function runtimePaths(env: Env = process.env, exists: (path: string) => b
110
115
  scheduleFile: value(env, "ZGW_SCHEDULE_FILE") ?? join(data, "schedule.json"),
111
116
  voiceSettingFile: value(env, "ZGW_VOICE_SETTING_FILE") ?? join(data, "voice-setting"),
112
117
  securityCheckFile: value(env, "ZGW_SECURITY_CHECK_FILE") ?? join(data, "security-check"),
118
+ repoSecurityCheckFile: value(env, "ZGW_REPO_SECURITY_CHECK_FILE") ?? join(data, "repo-security-check"),
119
+ formalVerificationFile: value(env, "ZGW_FORMAL_VERIFICATION_FILE") ?? join(data, "formal-verification.json"),
120
+ formalToolsDir: value(env, "ZGW_FORMAL_TOOLS_DIR") ?? join(home, "formal-tools"),
113
121
  logs: join(home, "logs"),
114
122
  secrets,
115
123
  omoTokenFile: value(env, "ZGW_OMO_TOKEN_FILE") ?? join(secrets, "omo-ws-token"),
@@ -135,3 +143,11 @@ export function runtimePaths(env: Env = process.env, exists: (path: string) => b
135
143
  },
136
144
  };
137
145
  }
146
+
147
+ /** Baseline consumers share runtime home resolution without changing the gateway's existing path object. */
148
+ export function securityBaselinePaths(env: Env = process.env, paths: Pick<RuntimePaths, "secrets"> = runtimePaths(env)) {
149
+ return {
150
+ securityBaselineFile: value(env, "ZGW_SECURITY_BASELINE_FILE") ?? join(paths.secrets, "security-baseline.json"),
151
+ legacyPrivateStringsFile: value(env, "ZIGMINI_PRIVATE_STRINGS") ?? join(value(env, "HOME") ?? homedir(), ".zigmini-release", "private-strings"),
152
+ };
153
+ }