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