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
@@ -0,0 +1,113 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { homedir, userInfo } from "node:os";
3
+ import { join } from "node:path";
4
+ import { SCHEMA_VERSION, writeBaselineAtomic, type BaselineFile } from "./security/baseline.ts";
5
+ import { machineTerms, normalizeTerm } from "./security/terms.ts";
6
+ import type { Store } from "./store.ts";
7
+
8
+ interface AutomaticValues {
9
+ readonly home: string;
10
+ readonly user: string;
11
+ }
12
+
13
+ interface BaselineExportOptions {
14
+ readonly secrets: string;
15
+ readonly gatewayVersion: string;
16
+ }
17
+
18
+ interface BaselineExportDeps {
19
+ readonly home?: string;
20
+ readonly user?: string;
21
+ readonly now?: () => Date;
22
+ readonly log?: (code: "write_failed") => void;
23
+ }
24
+
25
+ /** Account names and home paths remain protected even when an old allow row grants repo access. */
26
+ export function isAutomaticBaselineValue(value: string, automatic: AutomaticValues): boolean {
27
+ const normalized = normalizeTerm(value.trim());
28
+ return normalized === normalizeTerm(automatic.home) || normalized === normalizeTerm(automatic.user);
29
+ }
30
+
31
+ /** Automatic values stay in home/user; terms contain only explicit registrations. */
32
+ export function projectBaseline(input: Omit<BaselineFile, "schemaVersion">): BaselineFile {
33
+ const automatic = machineTerms(input);
34
+ return {
35
+ ...input, schemaVersion: SCHEMA_VERSION, revision: input.revision,
36
+ home: automatic.home,
37
+ terms: [...new Set(input.terms)],
38
+ allow: input.allow.map((entry) => ({
39
+ value: entry.value,
40
+ scopes: isAutomaticBaselineValue(entry.value, input)
41
+ ? entry.scopes.filter((scope) => scope !== "repo") : entry.scopes,
42
+ })),
43
+ };
44
+ }
45
+
46
+ /** The gateway's post-commit export boundary. File failure never rolls back a successful DB write. */
47
+ export class SecurityBaseline {
48
+ readonly baselineId: string;
49
+ exportedAt: string | null = null;
50
+ private exportError: "write_failed" | null = null;
51
+
52
+ constructor(private readonly store: Store, private readonly options: BaselineExportOptions,
53
+ private readonly deps: BaselineExportDeps = {}) {
54
+ const existing = store.getSetting("security.baselineId");
55
+ this.baselineId = existing ?? randomUUID();
56
+ if (existing === null) store.setSetting("security.baselineId", this.baselineId);
57
+ this.export();
58
+ }
59
+
60
+ auto(): AutomaticValues {
61
+ return { home: this.deps.home ?? homedir(), user: this.deps.user ?? userInfo().username };
62
+ }
63
+
64
+ isAutomaticValue(value: string): boolean {
65
+ return isAutomaticBaselineValue(value, this.auto());
66
+ }
67
+
68
+ status() {
69
+ return { baselineRevision: this.store.securityAllowedValues().revision,
70
+ baselineId: this.baselineId, exportError: this.exportError };
71
+ }
72
+
73
+ /** Called at startup and synchronously after each committed baseline or allow-list change. */
74
+ export(): void {
75
+ const { revision, values } = this.store.securityAllowedValues();
76
+ const file = projectBaseline({
77
+ baselineId: this.baselineId, revision,
78
+ terms: this.store.securityBaselineTerms().terms.map((term) => term.value),
79
+ ...this.auto(),
80
+ allow: values.map((entry) => ({ value: entry.value, scopes: entry.scopes ?? ["transport"] })),
81
+ exportedAt: (this.deps.now?.() ?? new Date()).toISOString(),
82
+ gatewayVersion: this.options.gatewayVersion,
83
+ });
84
+ // The writer always belongs to this gateway home, not a scanner's input-file override.
85
+ const result = writeBaselineAtomic(join(this.options.secrets, "security-baseline.json"), file);
86
+ if (!result.ok) {
87
+ this.exportError = result.error;
88
+ if (this.deps.log) this.deps.log(result.error);
89
+ else console.error("security baseline export failed", result.error);
90
+ return;
91
+ }
92
+ this.exportError = null;
93
+ this.exportedAt = file.exportedAt;
94
+ }
95
+
96
+ addTerm(value: string, expectedRevision?: number) {
97
+ const result = this.store.addSecurityBaselineTerm(value, expectedRevision);
98
+ this.export();
99
+ return result;
100
+ }
101
+
102
+ removeTerm(id: string, expectedRevision?: number) {
103
+ const result = this.store.removeSecurityBaselineTerm(id, expectedRevision);
104
+ if (result.removed > 0) this.export();
105
+ return result;
106
+ }
107
+
108
+ setAllowScopes(id: string, scopes: readonly string[], expectedRevision?: number) {
109
+ const result = this.store.setSecurityAllowScopes(id, scopes, expectedRevision);
110
+ this.export();
111
+ return result;
112
+ }
113
+ }
@@ -4,7 +4,9 @@ import allowConfig from "../scripts/secret-scan.allow.json";
4
4
  import { readVoiceSetting, writeVoiceSetting } from "./voice-setting.ts";
5
5
  import { isDeepStrictEqual } from "node:util";
6
6
  import { canonicalSecurityValue, SecurityRuntimeError, type HeldState, type SecurityApprover, type SecurityHoldRecord, type SecurityIntent, type SecurityReceipt } from "./security-allow.ts";
7
- import type { Store } from "./store.ts";
7
+ import { SecurityBaselineError, type Store } from "./store.ts";
8
+ import type { SecurityBaseline } from "./security-baseline.ts";
9
+ import { say, type Text } from "./server-text.ts";
8
10
  export type { HeldState, AllowedValue } from "./security-allow.ts";
9
11
 
10
12
  // Improvement 165: every user text bound for the engine passes one security check first (work 779e5b0e).
@@ -56,7 +58,7 @@ export const SECURITY_CHECK_PATH = "/api/settings/security-check";
56
58
  export const SECURITY_BLOCKED = "security_check_blocked";
57
59
  export const SECURITY_HELD_PATH = "/api/security-check/held";
58
60
 
59
- /** User-facing names of the finding kinds; rule ids without an entry fall back to "기타 보안 값". */
61
+ /** User-facing names of the finding kinds; rule ids without an entry fall back to "other". */
60
62
  const KIND_OF_RULE: Readonly<Record<string, string>> = {
61
63
  "private-key": "private-key",
62
64
  "ssh-public-key": "private-key",
@@ -69,16 +71,21 @@ const KIND_OF_RULE: Readonly<Record<string, string>> = {
69
71
  "email": "email", "phone-number": "phone",
70
72
  "adb-device": "device-id", "device-serial": "device-id",
71
73
  };
72
- export const KIND_LABELS: Readonly<Record<string, string>> = {
73
- "private-key": "비밀키",
74
- "token": "토큰·API 키",
75
- "credential": "비밀번호·인증·쿠키 값",
76
- "home-path": "홈 폴더 경로",
77
- "internal-address": "내부 IP·호스트",
78
- "email": "이메일 주소",
79
- "phone": "전화번호",
80
- "device-id": "기기 식별값",
81
- "other": "기타 보안 값",
74
+ export const KIND_LABELS: Readonly<Record<string, Text>> = {
75
+ "private-key": { en: "Private key", ko: "비밀키" },
76
+ "token": { en: "Token / API key", ko: "토큰·API 키" },
77
+ "credential": { en: "Password / auth / cookie value", ko: "비밀번호·인증·쿠키 값" },
78
+ "home-path": { en: "Home folder path", ko: "홈 폴더 경로" },
79
+ "internal-address": { en: "Internal IP / host", ko: "내부 IP·호스트" },
80
+ "email": { en: "Email address", ko: "이메일 주소" },
81
+ "phone": { en: "Phone number", ko: "전화번호" },
82
+ "device-id": { en: "Device identifier", ko: "기기 식별값" },
83
+ "other": { en: "Other security value", ko: "기타 보안 값" },
84
+ };
85
+ const FINDING_COUNT: Text = { en: "{label}: {count}", ko: "{label} {count}건" };
86
+ const HOLD_MESSAGE: Text = {
87
+ en: "The security check held this message and did not send it to the engine: {summary}.",
88
+ ko: "보안검사에 걸려 엔진에 보내지 않고 보류했습니다: {summary}.",
82
89
  };
83
90
 
84
91
  interface Compiled { readonly id: string; readonly kind: string; readonly regex: RegExp; readonly group: number; readonly minEntropy?: number }
@@ -135,7 +142,7 @@ export class SecurityChecker {
135
142
  const end = match.indices?.[rule.group]?.[1] ?? start + value.length;
136
143
  const before = text.slice(0, at);
137
144
  const line = before.split("\n").length;
138
- found.push({ kind: rule.kind, label: KIND_LABELS[rule.kind]!, value, text: index, line, column: at - before.lastIndexOf("\n"),
145
+ found.push({ kind: rule.kind, label: say(KIND_LABELS[rule.kind]!), value, text: index, line, column: at - before.lastIndexOf("\n"),
139
146
  ...(runtime ? { ruleId: rule.id, start, end,
140
147
  canonicalValue: canonicalSecurityValue(text, rule.id, start, end) } : {}),
141
148
  });
@@ -150,16 +157,16 @@ export function countFindings(occurrences: readonly SecurityOccurrence[]): Secur
150
157
  const counts = new Map<string, number>();
151
158
  for (const occurrence of occurrences) counts.set(occurrence.kind, (counts.get(occurrence.kind) ?? 0) + 1);
152
159
  return Object.keys(KIND_LABELS).filter((kind) => counts.has(kind))
153
- .map((kind) => ({ kind, label: KIND_LABELS[kind]!, count: counts.get(kind)! }));
160
+ .map((kind) => ({ kind, label: say(KIND_LABELS[kind]!), count: counts.get(kind)! }));
154
161
  }
155
162
 
156
163
  /** "토큰·API 키 2건, 홈 폴더 경로 1건" */
157
164
  export function findingSummary(findings: readonly SecurityFinding[]): string {
158
- return findings.map((finding) => `${finding.label} ${finding.count}건`).join(", ");
165
+ return findings.map((finding) => say(FINDING_COUNT, { label: finding.label, count: finding.count })).join(", ");
159
166
  }
160
167
 
161
168
  export function blockedMessage(findings: readonly SecurityFinding[]): string {
162
- return `보안검사에 걸려 엔진에 보내지 않고 보류했습니다: ${findingSummary(findings)}.`;
169
+ return say(HOLD_MESSAGE, { summary: findingSummary(findings) });
163
170
  }
164
171
 
165
172
  /** Thrown for a held decision answer; `held` (with the values) goes only to the user's screen that sent it. */
@@ -539,6 +546,74 @@ export async function handleHeld(
539
546
  }
540
547
 
541
548
  export const SECURITY_ALLOWED_PATH = `${SECURITY_CHECK_PATH}/allowed-values`;
549
+ export const SECURITY_BASELINE_PATH = `${SECURITY_CHECK_PATH}/baseline`;
550
+
551
+ /** Baseline reads/deletes use both authenticated ports; adding terms and changing scopes require the web port. */
552
+ export async function handleSecurityBaseline(
553
+ req: Request, url: URL, trusted: boolean, store: Store, baseline: SecurityBaseline,
554
+ publish: (revision: number) => void,
555
+ ): Promise<Response | null> {
556
+ const root = url.pathname === SECURITY_BASELINE_PATH;
557
+ const revision = url.pathname === `${SECURITY_BASELINE_PATH}/revision`;
558
+ const term = url.pathname.startsWith(`${SECURITY_BASELINE_PATH}/`) && !revision;
559
+ const scopes = req.method === "PATCH" && url.pathname.startsWith(`${SECURITY_ALLOWED_PATH}/`);
560
+ if (!root && !revision && !term && !scopes) return null;
561
+ const json = (value: unknown, status = 200) => Response.json(value, { status, headers: { "Cache-Control": "no-store" } });
562
+ if ((req.method === "POST" || req.method === "PATCH") && !trusted) return json({ error: "forbidden" }, 403);
563
+ try {
564
+ if (req.method === "GET") {
565
+ if (revision) return json({ revision: baseline.status().baselineRevision, baselineId: baseline.baselineId });
566
+ if (root) return json({
567
+ ...store.securityBaselineTerms(), baselineId: baseline.baselineId, auto: baseline.auto(),
568
+ exportedAt: baseline.exportedAt, exportError: baseline.status().exportError,
569
+ });
570
+ return null;
571
+ }
572
+ if (!(root && req.method === "POST") && !(term && req.method === "DELETE") && !scopes) return null;
573
+ const body: unknown = await req.json().catch(() => null);
574
+ if (typeof body !== "object" || body === null || !("expectedRevision" in body) ||
575
+ typeof body.expectedRevision !== "number" || !Number.isSafeInteger(body.expectedRevision) || body.expectedRevision < 0) {
576
+ return json({ error: "invalid_held_request" }, 400);
577
+ }
578
+ if (scopes) {
579
+ if (!("scopes" in body) || !Array.isArray(body.scopes) || !body.scopes.every((scope): scope is string => typeof scope === "string")) {
580
+ return json({ error: "scopes_invalid" }, 400);
581
+ }
582
+ const id = decodeURIComponent(url.pathname.slice(SECURITY_ALLOWED_PATH.length + 1));
583
+ const current = store.securityAllowedValues().values.find((entry) => entry.id === id);
584
+ if (body.scopes.includes("repo") && current && baseline.isAutomaticValue(current.value)) {
585
+ return json({ error: "scopes_invalid" }, 400);
586
+ }
587
+ const result = baseline.setAllowScopes(id, body.scopes, body.expectedRevision);
588
+ publish(result.revision);
589
+ return json(result);
590
+ }
591
+ if (root) {
592
+ if (!("value" in body) || typeof body.value !== "string") return json({ error: "term_invalid", reason: "empty" }, 400);
593
+ const result = baseline.addTerm(body.value, body.expectedRevision);
594
+ publish(result.revision);
595
+ return json(result);
596
+ }
597
+ const id = decodeURIComponent(url.pathname.slice(SECURITY_BASELINE_PATH.length + 1));
598
+ const result = baseline.removeTerm(id, body.expectedRevision);
599
+ if (result.removed === 0) return json({ error: "term_not_found" }, 404);
600
+ publish(result.revision);
601
+ return json(result);
602
+ } catch (error) {
603
+ if (error instanceof SecurityBaselineError) {
604
+ switch (error.code) {
605
+ case "baseline_conflict": return json({ error: error.code, revision: error.revision }, 409);
606
+ case "term_invalid": return json({ error: error.code, reason: error.reason }, 400);
607
+ case "term_duplicate":
608
+ case "scopes_invalid": return json({ error: error.code }, 400);
609
+ case "allowlist_not_found": return json({ error: error.code }, 404);
610
+ }
611
+ }
612
+ if (error instanceof SecurityRuntimeError && error.code === "allowlist_conflict") return json({ error: error.code, revision: error.revision }, 409);
613
+ return json({ error: scopes ? "allowlist_persist_failed" : "baseline_persist_failed" }, 503);
614
+ }
615
+ }
616
+
542
617
  export async function handleSecurityAllowedValues(req: Request, url: URL, store: Store, publish: (revision: number) => void): Promise<Response | null> {
543
618
  if (url.pathname !== SECURITY_ALLOWED_PATH && !url.pathname.startsWith(`${SECURITY_ALLOWED_PATH}/`)) return null;
544
619
  const json = (value: unknown, status = 200) => Response.json(value, { status, headers: { "Cache-Control": "no-store" } });
@@ -0,0 +1,38 @@
1
+ import type { Language } from "./cli/messages.ts";
2
+ import { DEFAULT_UI_LANGUAGE } from "./ui-language.ts";
3
+
4
+ /**
5
+ * Fixed text the gateway server writes for people (HTTP `message` fields, chat notices and errors, notifications,
6
+ * gateway-answered commands), in English and Korean (improvement 253). The language is the gateway-wide UI language
7
+ * (the install home's `language` file, src/ui-language.ts); main.ts sets the source once at startup. Machine values
8
+ * (`error`/`code` fields, event types) never go through here. Text is chosen when it is made: a notice already stored
9
+ * in the chat history keeps the language it was made in.
10
+ */
11
+
12
+ export type { Language };
13
+ export type Text = Readonly<Record<Language, string>>;
14
+
15
+ let source: () => Language = () => DEFAULT_UI_LANGUAGE;
16
+
17
+ /** main.ts: read the saved UI language each time text is made. Tests set a fixed language and restore it after. */
18
+ export function setServerLanguage(next: () => Language): void {
19
+ source = next;
20
+ }
21
+
22
+ export function serverLanguage(): Language {
23
+ return source();
24
+ }
25
+
26
+ /** The text in `lang`, with each `{name}` replaced by `values[name]`; an unknown placeholder stays as written. */
27
+ export function textIn(lang: Language, text: Text, values: Readonly<Record<string, string | number>> = {}): string {
28
+ return text[lang].replace(/\{(\w+)\}/g, (placeholder, name: string) => Object.hasOwn(values, name) ? String(values[name]) : placeholder);
29
+ }
30
+
31
+ export function say(text: Text, values: Readonly<Record<string, string | number>> = {}): string {
32
+ return textIn(source(), text, values);
33
+ }
34
+
35
+ /** True when `value` is the text in any language (for a reader that must recognize text made earlier or elsewhere). */
36
+ export function isTextOf(value: string, text: Text): boolean {
37
+ return value === text.en || value === text.ko;
38
+ }
package/src/store.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Database } from "bun:sqlite";
2
+ import { say, type Text } from "./server-text.ts";
2
3
  import { OMO_CHECK_BASELINE, omoCheckActionable, omoCheckDaytime } from "./omo-check-policy.ts";
3
4
  import { chmodSync, existsSync, mkdirSync } from "node:fs";
4
5
  import { join } from "node:path";
@@ -6,6 +7,23 @@ import { isDeepStrictEqual } from "node:util";
6
7
  import type { ConversationInfo, DecisionAnswerRequest, NotificationMode, ReplyNotification } from "./protocol.ts";
7
8
  import type { NativeTurnStats } from "./turn-stats.ts";
8
9
  import { SecurityRuntimeError, securityDecision, type AllowedValue, type HeldState, type SecurityApprover, type SecurityHoldRecord, type SecurityIntent, type SecurityReceipt } from "./security-allow.ts";
10
+ import { findPrivate, normalizeTerm, PUBLIC_ACCOUNT } from "./security/terms.ts";
11
+
12
+ export interface SecurityBaselineTerm {
13
+ readonly id: string;
14
+ readonly value: string;
15
+ readonly addedAt: string;
16
+ }
17
+
18
+ const UNTITLED_QUESTION: Text = { en: "Untitled question", ko: "제목 없는 질문" };
19
+
20
+ export class SecurityBaselineError extends Error {
21
+ constructor(
22
+ readonly code: "term_invalid" | "term_duplicate" | "baseline_conflict" | "scopes_invalid" | "allowlist_not_found",
23
+ readonly reason?: "empty" | "too_short" | "loopback",
24
+ readonly revision?: number,
25
+ ) { super(code); }
26
+ }
9
27
 
10
28
  export interface StoredEvent {
11
29
  seq: number;
@@ -566,6 +584,22 @@ export class Store {
566
584
  if (!columns.some((c) => c.name === "omo_cursor")) this.db.exec("ALTER TABLE conversations ADD COLUMN omo_cursor TEXT");
567
585
  if (!columns.some((c) => c.name === "session_started_at")) this.db.exec("ALTER TABLE conversations ADD COLUMN session_started_at TEXT");
568
586
  if (!columns.some((c) => c.name === "handoff")) this.db.exec("ALTER TABLE conversations ADD COLUMN handoff TEXT");
587
+ if (!columns.some((c) => c.name === "agent_id")) this.db.exec("ALTER TABLE conversations ADD COLUMN agent_id TEXT");
588
+ if (!columns.some((c) => c.name === "hidden")) this.db.exec("ALTER TABLE conversations ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0");
589
+ if (columns.some((c) => c.name === "persona_id")) {
590
+ this.db.transaction(() => {
591
+ const legacy = this.db.query<{ id: string; persona_id: string }, []>(
592
+ "SELECT id, persona_id FROM conversations WHERE agent_id IS NULL AND persona_id IS NOT NULL ORDER BY id",
593
+ ).all();
594
+ let collisions = 0;
595
+ for (const row of legacy) {
596
+ const existing = this.db.query<{ id: string }, [string]>("SELECT id FROM conversations WHERE agent_id = ?").get(row.persona_id);
597
+ if (existing) { collisions++; continue; }
598
+ this.db.query("UPDATE conversations SET agent_id = ? WHERE id = ?").run(row.persona_id, row.id);
599
+ }
600
+ if (collisions > 0) console.warn("agent_legacy_conversation_collisions", { count: collisions });
601
+ }).immediate();
602
+ }
569
603
  const memoColumns = this.db.query<{ name: string }, []>("PRAGMA table_info(memo)").all();
570
604
  if (!memoColumns.some((c) => c.name === "images")) this.db.exec("ALTER TABLE memo ADD COLUMN images TEXT NOT NULL DEFAULT '[]'");
571
605
  const deviceColumns = this.db.query<{ name: string }, []>("PRAGMA table_info(devices)").all();
@@ -642,6 +676,9 @@ export class Store {
642
676
  throw error;
643
677
  }
644
678
  this.db.exec(`
679
+ CREATE TABLE IF NOT EXISTS security_baseline_terms (
680
+ id TEXT PRIMARY KEY, value TEXT COLLATE BINARY NOT NULL UNIQUE, data TEXT NOT NULL
681
+ );
645
682
  CREATE TABLE IF NOT EXISTS security_allowed_values (
646
683
  id TEXT PRIMARY KEY, value TEXT COLLATE BINARY NOT NULL UNIQUE, data TEXT NOT NULL
647
684
  );
@@ -663,10 +700,70 @@ export class Store {
663
700
  return this.db.transaction(() => ({
664
701
  revision: this.db.query<{ revision: number }, []>("SELECT revision FROM security_allow_revision WHERE id = 1").get()!.revision,
665
702
  values: this.db.query<{ data: string }, []>("SELECT data FROM security_allowed_values ORDER BY json_extract(data, '$.approvedAt'), id").all()
666
- .map((row): AllowedValue => JSON.parse(row.data)),
703
+ .map((row): AllowedValue => {
704
+ const value: AllowedValue = JSON.parse(row.data);
705
+ return { ...value, scopes: value.scopes ?? ["transport"] };
706
+ }),
667
707
  })).immediate();
668
708
  }
669
709
 
710
+ securityBaselineTerms(): { revision: number; terms: SecurityBaselineTerm[] } {
711
+ return this.db.transaction(() => ({
712
+ revision: this.securityAllowedValues().revision,
713
+ terms: this.db.query<{ data: string }, []>("SELECT data FROM security_baseline_terms ORDER BY rowid").all()
714
+ .map((row): SecurityBaselineTerm => JSON.parse(row.data)),
715
+ })).immediate();
716
+ }
717
+
718
+ addSecurityBaselineTerm(value: string, expectedRevision?: number): { revision: number; term: SecurityBaselineTerm } {
719
+ const normalized = normalizeTerm(value.trim());
720
+ if (normalized.length === 0) throw new SecurityBaselineError("term_invalid", "empty");
721
+ if (normalized.length < 3) throw new SecurityBaselineError("term_invalid", "too_short");
722
+ // The private-list predicate owns LOOPBACK. Public-account masking is not a loopback rejection.
723
+ if (!normalized.match(PUBLIC_ACCOUNT) &&
724
+ !findPrivate(normalized, { terms: [normalized], hashes: new Set() }).some((hit) => hit.rule === "private-list")) {
725
+ throw new SecurityBaselineError("term_invalid", "loopback");
726
+ }
727
+ return this.db.transaction(() => {
728
+ const revision = this.securityAllowedValues().revision;
729
+ if (expectedRevision !== undefined && revision !== expectedRevision) throw new SecurityBaselineError("baseline_conflict", undefined, revision);
730
+ if (this.db.query("SELECT id FROM security_baseline_terms WHERE value = ?").get(normalized)) throw new SecurityBaselineError("term_duplicate");
731
+ const term: SecurityBaselineTerm = { id: crypto.randomUUID(), value: normalized, addedAt: new Date().toISOString() };
732
+ this.db.query("INSERT INTO security_baseline_terms VALUES (?, ?, ?)").run(term.id, normalized, JSON.stringify(term));
733
+ this.db.query("UPDATE security_allow_revision SET revision = revision + 1 WHERE id = 1").run();
734
+ return { revision: revision + 1, term };
735
+ }).immediate();
736
+ }
737
+
738
+ removeSecurityBaselineTerm(id: string, expectedRevision?: number): { revision: number; removed: number } {
739
+ return this.db.transaction(() => {
740
+ let revision = this.securityAllowedValues().revision;
741
+ if (expectedRevision !== undefined && revision !== expectedRevision) throw new SecurityBaselineError("baseline_conflict", undefined, revision);
742
+ const removed = this.db.query("DELETE FROM security_baseline_terms WHERE id = ?").run(id).changes;
743
+ if (removed > 0) {
744
+ this.db.query("UPDATE security_allow_revision SET revision = revision + 1 WHERE id = 1").run();
745
+ revision++;
746
+ }
747
+ return { revision, removed };
748
+ }).immediate();
749
+ }
750
+
751
+ setSecurityAllowScopes(id: string, scopes: readonly string[], expectedRevision?: number): { revision: number; value: AllowedValue } {
752
+ if (scopes[0] !== "transport" || (scopes.length !== 1 && (scopes.length !== 2 || scopes[1] !== "repo"))) {
753
+ throw new SecurityBaselineError("scopes_invalid");
754
+ }
755
+ return this.db.transaction(() => {
756
+ const { revision, values } = this.securityAllowedValues();
757
+ if (expectedRevision !== undefined && revision !== expectedRevision) throw new SecurityRuntimeError("allowlist_conflict", undefined, revision);
758
+ const current = values.find((value) => value.id === id);
759
+ if (!current) throw new SecurityBaselineError("allowlist_not_found");
760
+ const value: AllowedValue = { ...current, scopes: scopes.length === 1 ? ["transport"] : ["transport", "repo"] };
761
+ this.db.query("UPDATE security_allowed_values SET data = ? WHERE id = ?").run(JSON.stringify(value), id);
762
+ this.db.query("UPDATE security_allow_revision SET revision = revision + 1 WHERE id = 1").run();
763
+ return { revision: revision + 1, value };
764
+ }).immediate();
765
+ }
766
+
670
767
  securityHolds(): SecurityHoldRecord[] {
671
768
  return this.db.query<{ data: string }, []>("SELECT data FROM security_holds ORDER BY rowid").all().map((row) => JSON.parse(row.data));
672
769
  }
@@ -736,7 +833,7 @@ export class Store {
736
833
  for (const occurrence of occurrences) {
737
834
  const value = occurrence.canonicalValue ?? occurrence.value;
738
835
  const entry: AllowedValue = { id: crypto.randomUUID(), value, kind: occurrence.kind, label: occurrence.label,
739
- approvedAt: new Date().toISOString(), approvedBy: approver, heldId: id };
836
+ approvedAt: new Date().toISOString(), approvedBy: approver, heldId: id, scopes: ["transport"] };
740
837
  added += this.db.query("INSERT OR IGNORE INTO security_allowed_values VALUES (?, ?, ?)").run(entry.id, value, JSON.stringify(entry)).changes;
741
838
  }
742
839
  if (added > 0) {
@@ -1116,25 +1213,35 @@ export class Store {
1116
1213
  return rows.map((r) => ({ ...r, data: JSON.parse(r.data) as unknown }));
1117
1214
  }
1118
1215
 
1119
- listConversations(): ConversationInfo[] {
1216
+ /** Internal callers retain access to hidden rows; snapshots request visible rows only. */
1217
+ listConversations(includeHidden = true): ConversationInfo[] {
1120
1218
  const rows = this.db
1121
- .query<{ id: string; title: string; thread_id: string | null; created_at: string }, []>(
1122
- "SELECT id, title, thread_id, created_at FROM conversations ORDER BY created_at",
1219
+ .query<{ id: string; title: string; thread_id: string | null; created_at: string; agent_id: string | null }, [number]>(
1220
+ "SELECT id, title, thread_id, created_at, agent_id FROM conversations WHERE ? OR hidden = 0 ORDER BY created_at",
1123
1221
  )
1124
- .all();
1125
- return rows.map((r) => ({ id: r.id, title: r.title, threadId: r.thread_id, createdAt: r.created_at }));
1222
+ .all(Number(includeHidden));
1223
+ return rows.map((r) => ({ id: r.id, title: r.title, threadId: r.thread_id, createdAt: r.created_at, agentId: r.agent_id }));
1126
1224
  }
1127
1225
 
1128
1226
  getConversation(id: string): ConversationInfo | null {
1129
1227
  return this.listConversations().find((c) => c.id === id) ?? null;
1130
1228
  }
1131
1229
 
1132
- createConversation(id: string, title: string): ConversationInfo {
1133
- const createdAt = new Date().toISOString();
1134
- this.db.query("INSERT OR IGNORE INTO conversations (id, title, thread_id, created_at) VALUES (?, ?, NULL, ?)").run(id, title, createdAt);
1135
- const conv = this.getConversation(id);
1136
- if (!conv) throw new Error(`conversation_create_failed:${id}`);
1137
- return conv;
1230
+ createConversation(id: string, title: string, agentId?: string): ConversationInfo {
1231
+ return this.db.transaction(() => {
1232
+ if (agentId !== undefined && this.db.query("SELECT id FROM conversations WHERE agent_id = ?").get(agentId)) {
1233
+ throw new Error("agent_conversation_exists");
1234
+ }
1235
+ const createdAt = new Date().toISOString();
1236
+ this.db.query("INSERT OR IGNORE INTO conversations (id, title, thread_id, created_at, agent_id) VALUES (?, ?, NULL, ?, ?)").run(id, title, createdAt, agentId ?? null);
1237
+ const conv = this.getConversation(id);
1238
+ if (!conv) throw new Error(`conversation_create_failed:${id}`);
1239
+ return conv;
1240
+ }).immediate();
1241
+ }
1242
+
1243
+ setConversationHidden(id: string, hidden: boolean): void {
1244
+ this.db.query("UPDATE conversations SET hidden = ? WHERE id = ?").run(Number(hidden), id);
1138
1245
  }
1139
1246
 
1140
1247
  setThread(convId: string, threadId: string): void {
@@ -1171,11 +1278,15 @@ export class Store {
1171
1278
  }).immediate();
1172
1279
  }
1173
1280
 
1174
- /** Acceptance, unsent intent and the optional receipt link commit together before session admission. */
1175
- acceptMainUser(conv: string, data: unknown, receipt?: (ref: SecurityReceipt) => void): StoredEvent {
1281
+ /**
1282
+ * Acceptance, unsent intent and the optional receipt link commit together before session admission. A `handled`
1283
+ * message (improvement 230: `/compact`) is closed in the same transaction, so recovery never sends it as a prompt.
1284
+ */
1285
+ acceptMainUser(conv: string, data: unknown, receipt?: (ref: SecurityReceipt) => void, handled = false): StoredEvent {
1176
1286
  return this.db.transaction(() => {
1177
1287
  const message = this.appendEvent(conv, "message.user", data);
1178
1288
  this.appendEvent(conv, "main.user.pending", { message_seq: message.seq });
1289
+ if (handled) this.appendEvent(conv, "main.user.dispatched", { message_seq: message.seq, reason: "compact" });
1179
1290
  receipt?.({ kind: "message", conv, messageSeq: message.seq });
1180
1291
  return message;
1181
1292
  }).immediate();
@@ -1230,6 +1341,21 @@ export class Store {
1230
1341
  return rows.map((r) => ({ ...r, data: JSON.parse(r.data) as unknown }));
1231
1342
  }
1232
1343
 
1344
+ /**
1345
+ * Text of the user and assistant messages in the one conversation that belongs to `agentId`.
1346
+ * The agent join is the only scope: no caller-supplied conversation id exists. `%`, `_` and `\` in the query match literally.
1347
+ */
1348
+ searchAgentHistory(agentId: string, query: string, limit: number): { seq: number; type: string; ts: string; text: string }[] {
1349
+ if (query === "" || limit < 1) return [];
1350
+ const pattern = `%${query.replace(/[\\%_]/g, "\\$&")}%`;
1351
+ return this.db.query<{ seq: number; type: string; ts: string; text: string }, [string, string, number]>(`
1352
+ SELECT e.seq, e.type, e.ts, json_extract(e.data, '$.text') AS text
1353
+ FROM events AS e JOIN conversations AS c ON c.id = e.conv
1354
+ WHERE c.agent_id = ? AND e.type IN ('message.user', 'message.final')
1355
+ AND json_extract(e.data, '$.text') LIKE ? ESCAPE '\\'
1356
+ ORDER BY e.seq DESC LIMIT ?`).all(agentId, pattern, Math.min(limit, 50));
1357
+ }
1358
+
1233
1359
  getSetting(key: string): string | null {
1234
1360
  return this.db.query<{ value: string }, [string]>("SELECT value FROM settings WHERE key = ?").get(key)?.value ?? null;
1235
1361
  }
@@ -2509,7 +2635,7 @@ export class Store {
2509
2635
  .map((row) => {
2510
2636
  const payload: unknown = JSON.parse(row.payload);
2511
2637
  const title = object(payload) ? payload.title : null;
2512
- return typeof title === "string" && title.trim() !== "" ? title : "제목 없는 질문";
2638
+ return typeof title === "string" && title.trim() !== "" ? title : say(UNTITLED_QUESTION);
2513
2639
  });
2514
2640
  }
2515
2641
 
package/src/transcript.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { say, type Text } from "./server-text.ts";
3
4
  import type { Store } from "./store.ts";
4
5
 
5
6
  export interface TranscriptRecord {
@@ -15,6 +16,8 @@ export interface TranscriptRecord {
15
16
  }
16
17
 
17
18
  const BACKFILL_MARKER = ".backfilled-v1";
19
+ /** Improvement 253: the transcript line of a session change, in the UI language when the line is written. */
20
+ export const SESSION_ROTATED_TEXT: Text = { en: "Engine session switched ({reason}): {from} -> {to}", ko: "엔진 세션 교체 ({reason}): {from} -> {to}" };
18
21
 
19
22
  /**
20
23
  * Append-only conversation log: `<dir>/<conv>/<YYYY-MM-DD>.jsonl`, one JSON record per line, dated in `timeZone`.
@@ -62,7 +65,10 @@ export function toRecord(type: string, data: unknown, base: { ts: string; conv:
62
65
  if (type === "message.user" && typeof d.text === "string") return { ...base, role: "user", text: d.text, src: d.src, trust: d.trust };
63
66
  if (type === "message.final" && typeof d.text === "string") return { ...base, role: "assistant", text: d.text };
64
67
  if (type === "session.rotated") {
65
- return { ...base, role: "system", event: "session.rotated", text: `엔진 세션 교체 (${String(d.reason)}): ${String(d.from)} -> ${String(d.to)}` };
68
+ return { ...base, role: "system", event: "session.rotated", text: say(SESSION_ROTATED_TEXT, { reason: String(d.reason), from: String(d.from), to: String(d.to) }) };
69
+ }
70
+ if (type === "agent.applied") {
71
+ return { ...base, role: "system", event: "agent.applied", text: JSON.stringify({ sha: d.sha, at: d.at }) };
66
72
  }
67
73
  return null;
68
74
  }
@@ -0,0 +1,33 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { isLanguage, LANGUAGE_FILE, type Language, saveLanguage } from "./cli/messages.ts";
4
+
5
+ /**
6
+ * The gateway-wide UI language of the web and the app (English or Korean). It lives in the install home's `language`
7
+ * file, the same file the setup wizard writes. A missing or invalid file means English; the locale is not consulted.
8
+ */
9
+
10
+ export const UI_LANGUAGE_PATH = "/api/settings/language";
11
+ export const UI_LANGUAGE_EVENT = "ui-language.updated";
12
+ export const DEFAULT_UI_LANGUAGE: Language = "en";
13
+
14
+ export function readUiLanguage(home: string): Language {
15
+ const file = join(home, LANGUAGE_FILE);
16
+ let saved: string | null = null;
17
+ try { saved = existsSync(file) ? readFileSync(file, "utf8").trim() : null; } catch { saved = null; }
18
+ return isLanguage(saved) ? saved : DEFAULT_UI_LANGUAGE;
19
+ }
20
+
21
+ /** GET and PUT of UI_LANGUAGE_PATH on both ports (the app port is already behind the device gate). */
22
+ export async function handleUiLanguage(req: Request, home: string, publish: (language: Language) => void): Promise<Response | null> {
23
+ const json = (value: unknown, status = 200) => Response.json(value, { status, headers: { "Cache-Control": "no-store" } });
24
+ if (req.method === "GET") return json({ language: readUiLanguage(home) });
25
+ if (req.method !== "PUT") return null;
26
+ const body = (await req.json().catch(() => null)) as { language?: unknown } | null;
27
+ const language = body?.language;
28
+ if (!isLanguage(language)) return json({ error: "unknown_language" }, 400);
29
+ const before = readUiLanguage(home);
30
+ saveLanguage(home, language);
31
+ if (language !== before) publish(language);
32
+ return json({ language });
33
+ }
@@ -1,6 +1,7 @@
1
1
  import { PROTOCOL_VERSION, type Envelope } from "../protocol.ts";
2
2
  import type { InstallKind } from "../runtime/install-kind.ts";
3
3
  import type { Env } from "../runtime/paths.ts";
4
+ import { say, type Text } from "../server-text.ts";
4
5
 
5
6
  /**
6
7
  * New version notice (plan Todo 22). The gateway reads `dist-tags.latest` of the `zigmini` package from the npm registry
@@ -14,6 +15,9 @@ export const PACKAGE_NAME = "zigmini";
14
15
  export const DEFAULT_REGISTRY = "https://registry.npmjs.org";
15
16
  export const CHECK_INTERVAL_MS = 6 * 60 * 60_000;
16
17
  export const LOOKUP_TIMEOUT_MS = 15_000;
18
+ const REGISTRY_HTTP: Text = { en: "Registry HTTP {status}", ko: "레지스트리 HTTP {status}" };
19
+ const NO_LATEST: Text = { en: "The registry response has no dist-tags.latest version", ko: "레지스트리 응답에 dist-tags.latest 버전이 없음" };
20
+ const TIMED_OUT: Text = { en: "Timed out ({seconds} s)", ko: "시간 초과 ({seconds}초)" };
17
21
  export const RETRY_MS = 5 * 60_000;
18
22
  export const UPDATE_STATUS_PATH = "/api/update/status";
19
23
  export const UPDATE_CHECK_PATH = "/api/update/check";
@@ -77,13 +81,13 @@ export type LookupResult = { readonly ok: true; readonly latest: string } | { re
77
81
  export async function lookupLatest(url: string, fetcher: typeof fetch, timeoutMs: number): Promise<LookupResult> {
78
82
  try {
79
83
  const res = await fetcher(url, { signal: AbortSignal.timeout(timeoutMs), headers: { accept: "application/vnd.npm.install-v1+json, application/json" } });
80
- if (!res.ok) return { ok: false, error: `레지스트리 HTTP ${res.status}` };
84
+ if (!res.ok) return { ok: false, error: say(REGISTRY_HTTP, { status: res.status }) };
81
85
  const body = (await res.json()) as { "dist-tags"?: { latest?: unknown } } | null;
82
86
  const latest = body?.["dist-tags"]?.latest;
83
- if (typeof latest !== "string" || parseVersion(latest) === null) return { ok: false, error: "레지스트리 응답에 dist-tags.latest 버전이 없음" };
87
+ if (typeof latest !== "string" || parseVersion(latest) === null) return { ok: false, error: say(NO_LATEST) };
84
88
  return { ok: true, latest };
85
89
  } catch (error) {
86
- if (error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError")) return { ok: false, error: `시간 초과 (${timeoutMs / 1000}초)` };
90
+ if (error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError")) return { ok: false, error: say(TIMED_OUT, { seconds: timeoutMs / 1000 }) };
87
91
  return { ok: false, error: error instanceof Error ? error.message : String(error) };
88
92
  }
89
93
  }