zigmini 1.0.0-beta.1 → 1.0.0-beta.3
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 +21 -1
- package/README.md +21 -1
- package/assets/agent/gateway-rules.md +29 -19
- 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 +15 -1
- package/docs/manual.md +15 -1
- package/host/omo-check/patches/omo-5.1.51-chatgpt-terra.patch +21 -0
- package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +19 -3
- package/package.json +1 -1
- package/src/agent-rules.ts +86 -11
- package/src/cli/doctor.ts +7 -0
- package/src/cli/main.ts +5 -0
- package/src/cli/messages.ts +1 -1
- package/src/cli/rules.ts +70 -0
- package/src/engine/compat.json +1 -1
- package/src/main.ts +2 -1
- package/src/profile.ts +13 -5
- package/src/security/baseline.ts +3 -1
- package/src/usage.ts +18 -7
- package/web/DESIGN.md +2 -1
- package/web/profile.js +5 -4
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": 1,
|
|
3
|
-
"revision":
|
|
3
|
+
"revision": 4,
|
|
4
4
|
"upstream": {
|
|
5
5
|
"omo": "5.1.51",
|
|
6
6
|
"senpi": "2026.10.27-1"
|
|
7
7
|
},
|
|
8
|
-
"candidate": "<zigmini home>/omo/5.1.51-zgw.
|
|
8
|
+
"candidate": "<zigmini home>/omo/5.1.51-zgw.4",
|
|
9
9
|
"pristine": "<zigmini home>/omo/5.1.51",
|
|
10
10
|
"entrypoint": "node_modules/@code-yeongyu/senpi/dist/cli.js",
|
|
11
11
|
"patchRoot": "node_modules",
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"omo-5.1.51-runtime-compat.patch",
|
|
17
17
|
"omo-5.1.51-compaction-recovery.patch",
|
|
18
18
|
"omo-5.1.51-provider-failover.patch",
|
|
19
|
-
"omo-5.1.51-compaction-long-context.patch"
|
|
19
|
+
"omo-5.1.51-compaction-long-context.patch",
|
|
20
|
+
"omo-5.1.51-chatgpt-terra.patch"
|
|
20
21
|
],
|
|
21
22
|
"files": [
|
|
22
23
|
{
|
|
@@ -358,6 +359,21 @@
|
|
|
358
359
|
"path": "node_modules/@code-yeongyu/senpi/dist/core/extensions/builtin/anthropic-subscription/failover.js",
|
|
359
360
|
"originalSha256": "bcc9dbb3aaafbbd2840d95635c00d31d062ff1b61450ec039042873c71f8f51e",
|
|
360
361
|
"patchedSha256": "007bac87475fff79afd3070c023e668747399ae30156b4bf8db9e2bdab9c2d98"
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
"path": "node_modules/@earendil-works/pi-ai/dist/providers/data/chatgpt-subscription.json",
|
|
365
|
+
"originalSha256": "42b88c93d717b2a7469b71da7da5adf87347d0d9cc0f264b39117f0f940b51c0",
|
|
366
|
+
"patchedSha256": "2058226d5808194448c8e301a2c265238a608d0a97b6410672aff7f91bbfcf73"
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
"path": "node_modules/@code-yeongyu/senpi/dist/bundle/chunks/chunk-ZAS4NBVK.js",
|
|
370
|
+
"originalSha256": "ecb242933351a6c2535cb4283ed6fb7da2174b9326915fc82a2a5b6a0c162794",
|
|
371
|
+
"patchedSha256": "c3c4345fecec02a1070f5658e66547d0bb4f2199fa4f9b3eb0cdaa278986f2ac"
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
"path": "node_modules/@code-yeongyu/senpi/dist/bundle/chunks/session-worker.js",
|
|
375
|
+
"originalSha256": "8cd4a903ea9243667f63b131b9c3cb87ca3d9e1b46f8e8d54cfa2a11add853b2",
|
|
376
|
+
"patchedSha256": "cf6a2a7e06aef03bf8fdc3583578e66c0e0f35951bf6a373107f9e375e00bbd5"
|
|
361
377
|
}
|
|
362
378
|
]
|
|
363
379
|
}
|
package/package.json
CHANGED
package/src/agent-rules.ts
CHANGED
|
@@ -19,6 +19,9 @@ export const INSTALL_RULES_TEMPLATE = `<!--
|
|
|
19
19
|
이 파일은 이 설치에만 쓰는 에이전트 규칙입니다. zigmini 업데이트는 이 파일을 바꾸지 않습니다.
|
|
20
20
|
여기에 쓴 내용은 게이트웨이가 main과 작업 세션의 지시문 뒤에 붙입니다. 패키지 규칙과 부딪치면 이 파일이 이깁니다.
|
|
21
21
|
새 세션부터 적용됩니다(새 대화, 세션 교체, 브리지 재시작, 업데이트). 이미 열린 세션은 이전 내용을 씁니다.
|
|
22
|
+
공통 규칙 항목을 통째로 바꾸려면 이 파일의 항목 안에 replaces:<공통 ID>를 HTML 주석으로 적습니다(여는 표시 <!-- 바로 뒤에 replaces:ID, 그 뒤에 닫는 표시).
|
|
23
|
+
공통 항목의 번호·ID·첫 줄은 zigmini rules, 이 파일의 대체 상태는 zigmini rules check로 봅니다.
|
|
24
|
+
ID가 없는 항목은 새 규칙으로 뒤에 붙습니다. 공통에 없는 ID를 적으면 새 규칙으로 붙고 doctor가 경고합니다.
|
|
22
25
|
최대 32KB입니다. 더 크면 게이트웨이는 이 파일을 쓰지 않습니다. 이런 주석은 지시문에 붙지 않습니다.
|
|
23
26
|
-->
|
|
24
27
|
`;
|
|
@@ -33,32 +36,104 @@ export function packageRulesDrop(env: Env = process.env): RegExp | undefined {
|
|
|
33
36
|
return readVoiceSetting(runtimePaths(env).repoSecurityCheckFile) ? undefined : REPO_SECURITY_SECTION;
|
|
34
37
|
}
|
|
35
38
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
/** A package rule item carries a fixed id comment; it is assigned once and never derived from the text, so editing the text keeps it. */
|
|
40
|
+
export const RULE_ID = /<!--\s*id:([0-9a-f]{6})\s*-->/;
|
|
41
|
+
/** An install rule item that names a package rule id replaces that whole package item. */
|
|
42
|
+
export const RULE_REPLACES = /<!--\s*replaces:([0-9a-f]{6})\s*-->/;
|
|
43
|
+
const ITEM_START = /^\d+\.\s/;
|
|
44
|
+
|
|
45
|
+
export interface RuleItem {
|
|
46
|
+
readonly number: string;
|
|
47
|
+
readonly id: string | null;
|
|
48
|
+
readonly replaces: string | null;
|
|
49
|
+
readonly raw: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Splits rule text into the lines before the first numbered item and the numbered items; an item runs to the next `N. ` line. */
|
|
53
|
+
export function splitRuleItems(raw: string): { head: string; items: RuleItem[] } {
|
|
54
|
+
const head: string[] = [];
|
|
55
|
+
const items: string[][] = [];
|
|
56
|
+
for (const line of raw.split("\n")) {
|
|
57
|
+
if (ITEM_START.test(line)) items.push([line]);
|
|
58
|
+
else if (items.length === 0) head.push(line);
|
|
59
|
+
else items[items.length - 1]!.push(line);
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
head: head.join("\n"),
|
|
63
|
+
items: items.map((lines) => {
|
|
64
|
+
const text = lines.join("\n");
|
|
65
|
+
return { number: lines[0]!.match(/^(\d+)\./)![1]!, id: text.match(RULE_ID)?.[1] ?? null, replaces: text.match(RULE_REPLACES)?.[1] ?? null, raw: text };
|
|
66
|
+
}),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readRaw(path: string): { status: RulesStatus; raw: string } {
|
|
71
|
+
if (!existsSync(path)) return { status: "missing", raw: "" };
|
|
39
72
|
try {
|
|
40
|
-
if (statSync(path).size > AGENT_RULES_MAX_BYTES) return { status: "too_large",
|
|
41
|
-
raw
|
|
73
|
+
if (statSync(path).size > AGENT_RULES_MAX_BYTES) return { status: "too_large", raw: "" };
|
|
74
|
+
return { status: "used", raw: readFileSync(path, "utf8") };
|
|
42
75
|
} catch {
|
|
43
|
-
return { status: "unreadable",
|
|
76
|
+
return { status: "unreadable", raw: "" };
|
|
44
77
|
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function finish(status: RulesStatus, raw: string, drop?: RegExp): { status: RulesStatus; text: string } {
|
|
81
|
+
if (status !== "used") return { status, text: "" };
|
|
45
82
|
const text = (drop ? raw.replace(drop, "") : raw).replace(/<!--[\s\S]*?-->/g, "").trim();
|
|
46
83
|
return text === "" ? { status: "empty", text } : { status: "used", text };
|
|
47
84
|
}
|
|
48
85
|
|
|
86
|
+
export function readRulesFile(path: string, drop?: RegExp): { status: RulesStatus; text: string } {
|
|
87
|
+
const read = readRaw(path);
|
|
88
|
+
return finish(read.status, read.raw, drop);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type MappingState = "replaces" | "new" | "unknown_id";
|
|
92
|
+
|
|
93
|
+
export interface MappingEntry {
|
|
94
|
+
readonly number: string;
|
|
95
|
+
readonly replaces: string | null;
|
|
96
|
+
readonly state: MappingState;
|
|
97
|
+
/** The human number of the replaced package item, when the id is found. */
|
|
98
|
+
readonly target: string | null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** How each install rule item maps onto the package items: it replaces one, it is new, or it names an id the package no longer has. */
|
|
102
|
+
export function ruleMapping(packageRaw: string, installRaw: string): MappingEntry[] {
|
|
103
|
+
const byId = new Map(splitRuleItems(packageRaw).items.filter((item) => item.id !== null).map((item) => [item.id!, item.number]));
|
|
104
|
+
return splitRuleItems(installRaw).items.map((item) => {
|
|
105
|
+
if (item.replaces === null) return { number: item.number, replaces: null, state: "new", target: null };
|
|
106
|
+
const target = byId.get(item.replaces) ?? null;
|
|
107
|
+
return { number: item.number, replaces: item.replaces, state: target === null ? "unknown_id" : "replaces", target };
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The package text without the items an install item replaces (by id). */
|
|
112
|
+
export function dropReplaced(packageRaw: string, replaced: ReadonlySet<string>): string {
|
|
113
|
+
const { head, items } = splitRuleItems(packageRaw);
|
|
114
|
+
return [head, ...items.filter((item) => item.id === null || !replaced.has(item.id)).map((item) => item.raw)].join("\n");
|
|
115
|
+
}
|
|
116
|
+
|
|
49
117
|
/** The text to append after the role instructions, and one log line that names the files but never their content. */
|
|
50
118
|
export function agentRules(env: Env = process.env, packageFile: string = PACKAGE_RULES_FILE): { text: string; log: string } {
|
|
119
|
+
const installPath = join(runtimeHome(env), INSTALL_RULES_FILE);
|
|
120
|
+
const pkg = readRaw(packageFile);
|
|
121
|
+
const install = readRaw(installPath);
|
|
122
|
+
const mapping = install.status === "used" && pkg.status === "used" ? ruleMapping(pkg.raw, install.raw) : [];
|
|
123
|
+
const replaced = new Set(mapping.filter((entry) => entry.state === "replaces").map((entry) => entry.replaces!));
|
|
51
124
|
const sources = [
|
|
52
|
-
{ name: "package", path: packageFile, heading: PACKAGE_RULES_HEADING,
|
|
53
|
-
{ name: "install", path:
|
|
125
|
+
{ name: "package", path: packageFile, heading: PACKAGE_RULES_HEADING, read: finish(pkg.status, dropReplaced(pkg.raw, replaced), packageRulesDrop(env)) },
|
|
126
|
+
{ name: "install", path: installPath, heading: INSTALL_RULES_HEADING, read: finish(install.status, install.raw) },
|
|
54
127
|
];
|
|
55
128
|
const parts: string[] = [];
|
|
56
129
|
const notes: string[] = [];
|
|
57
130
|
for (const source of sources) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
notes.push(`${source.name}=${read.status === "used" ? `used(${source.path})` : read.status}`);
|
|
131
|
+
if (source.read.status === "used") parts.push(`${source.heading}\n\n${source.read.text}`);
|
|
132
|
+
notes.push(`${source.name}=${source.read.status === "used" ? `used(${source.path})` : source.read.status}`);
|
|
61
133
|
}
|
|
134
|
+
const unknown = mapping.filter((entry) => entry.state === "unknown_id").map((entry) => entry.replaces);
|
|
135
|
+
if (replaced.size > 0) notes.push(`replaced=${[...replaced].join(",")}`);
|
|
136
|
+
if (unknown.length > 0) notes.push(`unknown_ids=${unknown.join(",")}`);
|
|
62
137
|
return { text: parts.join("\n\n"), log: `agent rules: ${notes.join(" ")}` };
|
|
63
138
|
}
|
|
64
139
|
|
package/src/cli/doctor.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { type InstallKind } from "../runtime/install-kind.ts";
|
|
|
10
10
|
import { readVoiceSetting } from "../voice-setting.ts";
|
|
11
11
|
import { voiceLayout } from "../voice/install.ts";
|
|
12
12
|
import { parseUpdateStatus, UPDATE_STATUS_PATH, type UpdateStatus } from "../update/check.ts";
|
|
13
|
+
import { unknownRuleIds } from "./rules.ts";
|
|
13
14
|
import { accessItem, pingAccess, readAccess } from "./access.ts";
|
|
14
15
|
import { agentState, bootstrap, healthUrl, type LaunchdContext, type LaunchdDeps, ownership, plistPath, serviceLabels, waitReady } from "./launchd.ts";
|
|
15
16
|
|
|
@@ -31,6 +32,8 @@ export interface ConsentView { readonly state: "valid" | "stale" | "missing" | "
|
|
|
31
32
|
|
|
32
33
|
export interface DoctorDeps extends LaunchdDeps {
|
|
33
34
|
readonly portOpen: (port: number) => Promise<boolean>;
|
|
35
|
+
/** Ids in rules.md that the package rules lack; defaults to reading <home>/rules.md and the package file. */
|
|
36
|
+
readonly unknownRuleIds?: () => string[];
|
|
34
37
|
readonly probe: (url: string) => Promise<boolean>;
|
|
35
38
|
readonly doctorApi: (url: string) => Promise<DoctorCounts | null>;
|
|
36
39
|
/** Version folder name `omo/current` points at, or null. */
|
|
@@ -150,6 +153,10 @@ export async function collect(deps: DoctorDeps, ctx: LaunchdContext): Promise<Do
|
|
|
150
153
|
|
|
151
154
|
if (deps.access !== undefined) items.push(await deps.access());
|
|
152
155
|
|
|
156
|
+
const unknownIds = (deps.unknownRuleIds ?? (() => unknownRuleIds(ctx.paths.home)))();
|
|
157
|
+
if (unknownIds.length > 0) items.push({ id: "rules.mapping", label: "규칙 매핑", status: "warn",
|
|
158
|
+
detail: `rules.md가 공통 규칙에 없는 ID를 가리킵니다(${unknownIds.join(", ")}). 신규 규칙으로 붙습니다. \`zigmini rules check\`로 확인하세요` });
|
|
159
|
+
|
|
153
160
|
const consent = deps.consent();
|
|
154
161
|
items.push({ id: "consent", label: "권한 동의", status: consent.state === "valid" ? "ok" : consent.state === "skipped" ? "off" : "bad",
|
|
155
162
|
detail: consent.state === "valid" ? `안내문 버전 ${consent.version}` : consent.state === "skipped" ? "checkout 실행" : consent.state === "stale"
|
package/src/cli/main.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { type LaunchdContext, type LaunchdDeps, restartCommand, startCommand, statusCommand, stopCommand, systemContext, systemDeps, UPDATER_COMMAND } from "./launchd.ts";
|
|
4
4
|
import { cliGate, consentCommand, consentSummary, EXIT_DECLINED, loadNotice } from "../consent/consent.ts";
|
|
5
5
|
import { installKind } from "../runtime/install-kind.ts";
|
|
6
|
+
import { rulesCommand } from "./rules.ts";
|
|
6
7
|
import { doctorCommand, systemDoctorDeps } from "./doctor.ts";
|
|
7
8
|
import { setupCommand, stdinPrompt, systemSetupDeps } from "./setup.ts";
|
|
8
9
|
import { pairCommand } from "./pair.ts";
|
|
@@ -107,6 +108,10 @@ if (import.meta.main) {
|
|
|
107
108
|
const notice = () => loadNotice();
|
|
108
109
|
const hint = setupHint(argv[0], ctx.paths.home, kind);
|
|
109
110
|
if (hint !== null) deps.err(`${hint}\n\n`);
|
|
111
|
+
if (argv[0] === "rules") {
|
|
112
|
+
const lang = resolveLanguage(ctx.paths.home, ctx.env);
|
|
113
|
+
process.exit(rulesCommand(argv.slice(1), { home: ctx.paths.home, lang: lang === "en" ? "en" : "ko", out: deps.out, err: deps.err }));
|
|
114
|
+
}
|
|
110
115
|
if (argv[0] === "consent") {
|
|
111
116
|
if (argv.length !== 1) {
|
|
112
117
|
deps.err(`잘못된 인자: ${argv.join(" ")}\n\n${HELP}`);
|
package/src/cli/messages.ts
CHANGED
|
@@ -8,7 +8,7 @@ export const LANGUAGE_FILE = "language";
|
|
|
8
8
|
export const LANGUAGE_QUESTION = "Language / 언어 [1] English [2] 한국어: ";
|
|
9
9
|
|
|
10
10
|
const BASE_MESSAGES = {
|
|
11
|
-
help: { en: "zigmini - personal AI agent gateway\n\nNew installation: run `zigmini setup` for permission consent, engine installation, configuration and startup.\n\nUsage: zigmini <command> [options]\n\nCommands:\n security import-baseline [--from <file>] [--dry-run] Import baseline terms through the running gateway; report counts only\n setup Set up the install home, engine, configuration and launchd services\n start Register and start the gateway and bridge with launchd\n stop [--uninstall] Stop services; --uninstall also removes this package's launchd plists\n restart [gateway|bridge] Restart services\n status Show launchd, health and permission consent status\n doctor [--json] [--fix] Check the installation; --fix restarts only unhealthy launchd agents\n pair [--apk] Show a one-use phone enrollment QR, or an APK download QR (--apk); valid for 10 minutes\n rotate-main [--restart] [--wait <seconds>] [--foreground]\n Replace the main conversation's engine session after its turn ends (default wait: 900s).\n Runs detached by default; results: <home>/logs/rotate-main.log.\n --restart restarts the gateway first; --foreground runs in this terminal.\n consent Show and record full-access permission consent; stop on refusal\n update [version] [--force] Install a version (default: latest), back up the DB and restart; roll back on failure.\n --force allows active turns; the gateway must be running.\n migrate [--from <old home>] [--checkout <repository>] [--accept-license] [--dry-run]\n Copy a Docker installation (~/" + LEGACY_HOME + "); keep the old directory unchanged.\n migrate-run [--from <old home>] [--checkout <repository>] [--access ... --domain ...] [--dry-run]\n Wait for idle, stop, back up, migrate, set up and check; restore the old run on failure.\n\nSetup options:\n --lang <en|ko> Language (saved in <home>/language)\n --non-interactive Do not ask setup questions\n --accept-license Accept the engine license\n --agent-dir <path> Dedicated agent directory instead of shared ~/.omo/agent\n --notion-token-file <path> Notion token file; no token disables the calendar\n --access <tailscale|lan|domain>, --domain <https://address>\n Choose external access\n --engine-only Run only the pinned engine installation step\n\nOptions:\n --version, -v Print the installed zigmini version\n --help, -h Print this help\n", ko: "zigmini - 개인 AI 에이전트 게이트웨이\n\n처음 설치했으면 먼저 `zigmini setup`을 실행하세요. setup이 권한 동의, 엔진 설치, 설정, 시작을 합니다.\n\n사용법: zigmini <명령> [옵션]\n\n명령:\n security import-baseline [--from <파일>] [--dry-run] 실행 중인 게이트웨이로 기준 용어를 가져오고 건수만 출력합니다\n setup 설치합니다: 홈 폴더, 엔진, 설정, launchd 등록과 시작\n start 게이트웨이와 브리지를 launchd에 등록하고 시작합니다\n stop 게이트웨이와 브리지를 멈춥니다\n stop --uninstall 멈추고 이 패키지의 launchd plist를 모두 지웁니다\n restart [gateway|bridge] 다시 시작합니다\n status launchd 상태, health, 권한 동의 상태를 보여 줍니다\n doctor [--json] [--fix] 전체 상태를 점검합니다(--fix: 이상 있는 launchd 에이전트만 다시 띄움)\n pair 폰 등록 QR(유효 10분, 한 번 사용)을 터미널에 그립니다. 게이트웨이가 실행 중이어야 합니다\n pair --apk 앱(APK) 내려받기 QR(유효 10분)을 터미널에 그립니다\n rotate-main [--restart] [--wait <초>] [--foreground]\n main 대화를 새 엔진 세션으로 교체합니다. main 차례가 끝나길 기다린 뒤 보냅니다(기본 900초).\n 기본은 분리 실행이고 결과는 <홈>/logs/rotate-main.log에 남깁니다\n (--restart: 먼저 게이트웨이를 멈추고 다시 켭니다. --foreground: 이 터미널에서 실행)\n consent 풀 액세스 권한 안내를 보이고 동의를 기록합니다(거부하면 멈춤)\n update [버전] [--force] 새 버전으로 업데이트합니다(비우면 latest). DB 백업 뒤 설치·재시작하고, 실패하면 자동으로 되돌립니다\n (--force: 진행 중인 턴이 있어도 합니다. 게이트웨이가 실행 중이어야 합니다)\n migrate [--from <옛 홈>] [--checkout <저장소>] [--accept-license] [--dry-run]\n Docker 설치(~/" + LEGACY_HOME + ")를 복사해 옮깁니다. 옛 폴더는 바꾸지 않습니다\n migrate-run [--from <옛 홈>] [--checkout <저장소>] [--access ... --domain ...] [--dry-run]\n 유휴 대기 → 옛 실행 정지 → 백업 → migrate → setup → 확인, 실패하면 옛 실행으로 되돌립니다\n\nsetup 옵션:\n --lang <en|ko> Language / 언어 (saved in <home>/language)\n --non-interactive 묻지 않습니다\n --accept-license 엔진 라이선스에 동의합니다\n --agent-dir <경로> 공유 ~/.omo/agent 대신 쓸 전용 에이전트 폴더\n --notion-token-file <경로> Notion 토큰 파일(없으면 일정 기능 꺼짐)\n --access <tailscale|lan|domain>, --domain <https://주소>\n 외부 접속 선택\n --engine-only 엔진 설치 단계만 실행합니다\n\n옵션:\n --version, -v 설치된 zigmini 버전을 출력합니다\n --help, -h 이 도움말을 출력합니다\n" },
|
|
11
|
+
help: { en: "zigmini - personal AI agent gateway\n\nNew installation: run `zigmini setup` for permission consent, engine installation, configuration and startup.\n\nUsage: zigmini <command> [options]\n\nCommands:\n security import-baseline [--from <file>] [--dry-run] Import baseline terms through the running gateway; report counts only\n setup Set up the install home, engine, configuration and launchd services\n start Register and start the gateway and bridge with launchd\n stop [--uninstall] Stop services; --uninstall also removes this package's launchd plists\n restart [gateway|bridge] Restart services\n status Show launchd, health and permission consent status\n doctor [--json] [--fix] Check the installation; --fix restarts only unhealthy launchd agents\n rules [check] List common rule items (number, id, first line); check: how rules.md items replace them\n pair [--apk] Show a one-use phone enrollment QR, or an APK download QR (--apk); valid for 10 minutes\n rotate-main [--restart] [--wait <seconds>] [--foreground]\n Replace the main conversation's engine session after its turn ends (default wait: 900s).\n Runs detached by default; results: <home>/logs/rotate-main.log.\n --restart restarts the gateway first; --foreground runs in this terminal.\n consent Show and record full-access permission consent; stop on refusal\n update [version] [--force] Install a version (default: latest), back up the DB and restart; roll back on failure.\n --force allows active turns; the gateway must be running.\n migrate [--from <old home>] [--checkout <repository>] [--accept-license] [--dry-run]\n Copy a Docker installation (~/" + LEGACY_HOME + "); keep the old directory unchanged.\n migrate-run [--from <old home>] [--checkout <repository>] [--access ... --domain ...] [--dry-run]\n Wait for idle, stop, back up, migrate, set up and check; restore the old run on failure.\n\nSetup options:\n --lang <en|ko> Language (saved in <home>/language)\n --non-interactive Do not ask setup questions\n --accept-license Accept the engine license\n --agent-dir <path> Dedicated agent directory instead of shared ~/.omo/agent\n --notion-token-file <path> Notion token file; no token disables the calendar\n --access <tailscale|lan|domain>, --domain <https://address>\n Choose external access\n --engine-only Run only the pinned engine installation step\n\nOptions:\n --version, -v Print the installed zigmini version\n --help, -h Print this help\n", ko: "zigmini - 개인 AI 에이전트 게이트웨이\n\n처음 설치했으면 먼저 `zigmini setup`을 실행하세요. setup이 권한 동의, 엔진 설치, 설정, 시작을 합니다.\n\n사용법: zigmini <명령> [옵션]\n\n명령:\n security import-baseline [--from <파일>] [--dry-run] 실행 중인 게이트웨이로 기준 용어를 가져오고 건수만 출력합니다\n setup 설치합니다: 홈 폴더, 엔진, 설정, launchd 등록과 시작\n start 게이트웨이와 브리지를 launchd에 등록하고 시작합니다\n stop 게이트웨이와 브리지를 멈춥니다\n stop --uninstall 멈추고 이 패키지의 launchd plist를 모두 지웁니다\n restart [gateway|bridge] 다시 시작합니다\n status launchd 상태, health, 권한 동의 상태를 보여 줍니다\n doctor [--json] [--fix] 전체 상태를 점검합니다(--fix: 이상 있는 launchd 에이전트만 다시 띄움)\n rules [check] 공통 규칙 항목(번호, ID, 첫 줄)을 보여 줍니다(check: rules.md 항목의 대체 상태 점검)\n pair 폰 등록 QR(유효 10분, 한 번 사용)을 터미널에 그립니다. 게이트웨이가 실행 중이어야 합니다\n pair --apk 앱(APK) 내려받기 QR(유효 10분)을 터미널에 그립니다\n rotate-main [--restart] [--wait <초>] [--foreground]\n main 대화를 새 엔진 세션으로 교체합니다. main 차례가 끝나길 기다린 뒤 보냅니다(기본 900초).\n 기본은 분리 실행이고 결과는 <홈>/logs/rotate-main.log에 남깁니다\n (--restart: 먼저 게이트웨이를 멈추고 다시 켭니다. --foreground: 이 터미널에서 실행)\n consent 풀 액세스 권한 안내를 보이고 동의를 기록합니다(거부하면 멈춤)\n update [버전] [--force] 새 버전으로 업데이트합니다(비우면 latest). DB 백업 뒤 설치·재시작하고, 실패하면 자동으로 되돌립니다\n (--force: 진행 중인 턴이 있어도 합니다. 게이트웨이가 실행 중이어야 합니다)\n migrate [--from <옛 홈>] [--checkout <저장소>] [--accept-license] [--dry-run]\n Docker 설치(~/" + LEGACY_HOME + ")를 복사해 옮깁니다. 옛 폴더는 바꾸지 않습니다\n migrate-run [--from <옛 홈>] [--checkout <저장소>] [--access ... --domain ...] [--dry-run]\n 유휴 대기 → 옛 실행 정지 → 백업 → migrate → setup → 확인, 실패하면 옛 실행으로 되돌립니다\n\nsetup 옵션:\n --lang <en|ko> Language / 언어 (saved in <home>/language)\n --non-interactive 묻지 않습니다\n --accept-license 엔진 라이선스에 동의합니다\n --agent-dir <경로> 공유 ~/.omo/agent 대신 쓸 전용 에이전트 폴더\n --notion-token-file <경로> Notion 토큰 파일(없으면 일정 기능 꺼짐)\n --access <tailscale|lan|domain>, --domain <https://주소>\n 외부 접속 선택\n --engine-only 엔진 설치 단계만 실행합니다\n\n옵션:\n --version, -v 설치된 zigmini 버전을 출력합니다\n --help, -h 이 도움말을 출력합니다\n" },
|
|
12
12
|
permissionNotice: { en: "zigmini permission notice (notice version 1)\n\n1. The zigmini gateway runs the OMO agent with the full-access permission preset.\n2. The full-access preset allows every permission on every target. The agent does not ask for approval for each tool call.\n3. The agent has file read, write and edit tools, and a shell command (bash) tool.\n4. Shell commands run with this macOS account's permissions, without a sandbox or isolation.\n5. The agent uses the network for model API calls, web search (web_search) and web fetching (webfetch).\n6. The agent uses model accounts logged in through auth.json in the agent directory and consumes their usage allowance.\n7. The default agent directory is ~/.omo/agent. Other OMO instances using that directory share its login information (auth.json) and settings (settings.json).\n8. The gateway and bridge run as this macOS user's LaunchAgents. launchd restarts them after an error exit.\n9. Requests from enrolled phone apps run with the same full-access permissions.\n10. If you do not agree, zigmini installs nothing and does not start the gateway or bridge.", ko: "zigmini 권한 안내 (안내문 버전 1)\n\n1. zigmini 게이트웨이는 OMO 에이전트를 권한 프리셋 full-access로 실행합니다.\n2. full-access 프리셋은 모든 권한, 모든 대상에 '허용' 규칙 하나만 둡니다. 그래서 에이전트는 도구를 쓸 때마다 사용자 승인을 받지 않습니다.\n3. 에이전트에는 파일 읽기(read), 쓰기(write), 고치기(edit) 도구와 셸 명령(bash) 도구가 있습니다.\n4. 셸 명령은 샌드박스 없이 이 macOS 사용자 계정의 권한으로 실행됩니다. 격리는 없습니다.\n5. 에이전트는 네트워크를 씁니다: 모델 API 호출, 웹 검색(web_search), 웹 가져오기(webfetch).\n6. 에이전트는 에이전트 폴더의 auth.json에 로그인한 모델 계정을 쓰고, 그 계정의 사용량을 소비합니다.\n7. 기본 에이전트 폴더는 ~/.omo/agent입니다. 같은 폴더를 쓰는 다른 OMO와 로그인 정보(auth.json)와 설정(settings.json)을 같이 씁니다.\n8. 게이트웨이와 브리지는 이 macOS 사용자의 LaunchAgent로 실행되고, 오류로 끝나면 launchd가 다시 시작합니다.\n9. 등록한 폰 앱이 보낸 요청도 같은 full-access 권한으로 실행됩니다.\n10. 동의하지 않으면 zigmini는 아무것도 설치하지 않고, 게이트웨이와 브리지는 시작하지 않습니다." },
|
|
13
13
|
unknownOption: { en: "Unknown option: {flag}", ko: "알 수 없는 옵션: {flag}" },
|
|
14
14
|
missingValue: { en: "{flag} requires a value", ko: "{flag} 에 값이 필요합니다" },
|
package/src/cli/rules.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { INSTALL_RULES_FILE, PACKAGE_RULES_FILE, ruleMapping, splitRuleItems } from "../agent-rules.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `zigmini rules` lists the package (common) rule items with their number, fixed id and first line.
|
|
7
|
+
* `zigmini rules check` shows how each item of <home>/rules.md maps onto them: it replaces one (by id), it is new, or it
|
|
8
|
+
* names an id the package no longer has (exit 1). The numbers are only a reading order, never a priority.
|
|
9
|
+
*/
|
|
10
|
+
export interface RulesDeps {
|
|
11
|
+
readonly home: string;
|
|
12
|
+
readonly lang: "en" | "ko";
|
|
13
|
+
readonly out: (text: string) => void;
|
|
14
|
+
readonly err: (text: string) => void;
|
|
15
|
+
readonly packageFile?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const TEXT = {
|
|
19
|
+
en: { usage: "Usage: zigmini rules [check]", noPackage: "package rules file not found", noInstall: "no rules.md in this installation: nothing to map",
|
|
20
|
+
replaces: (id: string, target: string) => `replaces common ${target} (${id})`, fresh: "new rule (appended after the common rules)",
|
|
21
|
+
unknown: (id: string) => `unknown id ${id}: appended as a new rule; fix or remove the replaces comment` },
|
|
22
|
+
ko: { usage: "사용법: zigmini rules [check]", noPackage: "공통 규칙 파일이 없습니다", noInstall: "이 설치에 rules.md가 없습니다. 매핑할 항목이 없습니다",
|
|
23
|
+
replaces: (id: string, target: string) => `공통 ${target}번(${id})을 대체`, fresh: "신규 규칙(공통 규칙 뒤에 붙음)",
|
|
24
|
+
unknown: (id: string) => `없는 ID ${id}: 신규로 붙습니다. replaces 주석을 고치거나 지우세요` },
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
function firstLine(raw: string): string {
|
|
28
|
+
const line = raw.split("\n")[0]!.replace(/<!--[\s\S]*?-->/g, "").replace(/^\d+\.\s*/, "").trim();
|
|
29
|
+
return line.length > 60 ? `${line.slice(0, 60)}…` : line;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readText(path: string): string | null {
|
|
33
|
+
return existsSync(path) ? readFileSync(path, "utf8") : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function rulesCommand(argv: readonly string[], deps: RulesDeps): number {
|
|
37
|
+
const text = TEXT[deps.lang];
|
|
38
|
+
if (argv.length > 1 || (argv.length === 1 && argv[0] !== "check")) {
|
|
39
|
+
deps.err(`${text.usage}\n`);
|
|
40
|
+
return 2;
|
|
41
|
+
}
|
|
42
|
+
const pkg = readText(deps.packageFile ?? PACKAGE_RULES_FILE);
|
|
43
|
+
if (pkg === null) {
|
|
44
|
+
deps.err(`${text.noPackage}\n`);
|
|
45
|
+
return 2;
|
|
46
|
+
}
|
|
47
|
+
if (argv.length === 0) {
|
|
48
|
+
for (const item of splitRuleItems(pkg).items) deps.out(`${item.number.padStart(2)} ${item.id ?? "------"} ${firstLine(item.raw)}\n`);
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
const install = readText(join(deps.home, INSTALL_RULES_FILE));
|
|
52
|
+
if (install === null) {
|
|
53
|
+
deps.out(`${text.noInstall}\n`);
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
const mapping = ruleMapping(pkg, install);
|
|
57
|
+
for (const entry of mapping) {
|
|
58
|
+
const state = entry.state === "replaces" ? text.replaces(entry.replaces!, entry.target!) : entry.state === "new" ? text.fresh : text.unknown(entry.replaces!);
|
|
59
|
+
deps.out(`${entry.number.padStart(2)} ${state}\n`);
|
|
60
|
+
}
|
|
61
|
+
return mapping.some((entry) => entry.state === "unknown_id") ? 1 : 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Ids named in rules.md that the package rules no longer have (doctor warns about them). */
|
|
65
|
+
export function unknownRuleIds(home: string, packageFile: string = PACKAGE_RULES_FILE): string[] {
|
|
66
|
+
const pkg = readText(packageFile);
|
|
67
|
+
const install = readText(join(home, INSTALL_RULES_FILE));
|
|
68
|
+
if (pkg === null || install === null) return [];
|
|
69
|
+
return ruleMapping(pkg, install).filter((entry) => entry.state === "unknown_id").map((entry) => entry.replaces!);
|
|
70
|
+
}
|
package/src/engine/compat.json
CHANGED
package/src/main.ts
CHANGED
|
@@ -39,7 +39,7 @@ import { CHAT_TOPIC, showsThinking, THINKING_TOPIC, topicOf } from "./client-top
|
|
|
39
39
|
import { APK_DIR, APK_PATH, APP_DOWNLOAD_LINK_PATH, APP_VERSION_PATH, ApkSource, appVersion, createDownloadLink, DownloadLinks, serveApk } from "./app-download.ts";
|
|
40
40
|
import { loadConfig } from "./config.ts";
|
|
41
41
|
import { memoUpdatedData, parseMemoPatch } from "./memo-images.ts";
|
|
42
|
-
import { readProfile, saveProfile } from "./profile.ts";
|
|
42
|
+
import { fixAgentName, readProfile, saveProfile } from "./profile.ts";
|
|
43
43
|
import { handleProfileIcon, PROFILE_ICON_PATH, ProfileIcon, profileUpdated } from "./profile-icon.ts";
|
|
44
44
|
import { Hub, type ScopedQuestionFrame } from "./hub.ts";
|
|
45
45
|
import { parseSummaryModels, SpeechSummarizer } from "./speech-summary.ts";
|
|
@@ -97,6 +97,7 @@ const gcRoots: GcRoots = { kind: installKind(), home: gcHome.home, backups: join
|
|
|
97
97
|
const gcDeps = systemGcDeps(join(import.meta.dir, ".."), { ...process.env, ZIGMINI_HOME: gcHome.home });
|
|
98
98
|
const store = new Store(config.dataDir);
|
|
99
99
|
const GATEWAY_ID = gatewayId(store);
|
|
100
|
+
fixAgentName(store);
|
|
100
101
|
const GATEWAY_VERSION = (JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }).version;
|
|
101
102
|
const securityBaseline = new SecurityBaseline(store, { secrets: runtime.secrets, gatewayVersion: GATEWAY_VERSION });
|
|
102
103
|
// Improvement 250: formal verification switches; installs run in the gateway process and report through this event.
|
package/src/profile.ts
CHANGED
|
@@ -4,7 +4,11 @@ import { isTextOf, say, type Text } from "./server-text.ts";
|
|
|
4
4
|
import type { Store } from "./store.ts";
|
|
5
5
|
|
|
6
6
|
export const NICKNAME_SETTING = "profile.nickname";
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Built-in default names. The nickname default follows the saved UI language; a stored built-in nickname of either
|
|
9
|
+
* language reads as the current one. The agent name is not translated (bug 271): fixAgentName stores the default once
|
|
10
|
+
* at startup and every saved agent name, "Zig" and "지그" included, stays as written in every language.
|
|
11
|
+
*/
|
|
8
12
|
export const NICKNAME_DEFAULT: Text = { en: "Me", ko: "나" };
|
|
9
13
|
export const NICKNAME_MAX = 20;
|
|
10
14
|
|
|
@@ -37,10 +41,9 @@ function ownNickname(value: string): string {
|
|
|
37
41
|
return isTextOf(name, NICKNAME_DEFAULT) ? "" : name;
|
|
38
42
|
}
|
|
39
43
|
|
|
40
|
-
/** The
|
|
44
|
+
/** The agent name as saved: whitespace collapsed and trimmed; "" for blank. */
|
|
41
45
|
function ownAgentName(value: string): string {
|
|
42
|
-
|
|
43
|
-
return isTextOf(name, AGENT_NAME_DEFAULT) ? "" : name;
|
|
46
|
+
return value.replace(/\s+/g, " ").trim();
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
/** The nickname to show: the user's own, else the current language's default. */
|
|
@@ -83,13 +86,18 @@ export function readProfile(store: Pick<Store, "getSetting">): Profile {
|
|
|
83
86
|
* Saves the `nickname` and/or `agentName` of a PUT body and returns the whole profile. Nothing is written unless every
|
|
84
87
|
* given field is valid. A body with neither field is `nickname_required`, the error older clients already know.
|
|
85
88
|
*/
|
|
89
|
+
/** Gateway startup: an install without a saved agent name stores the default of the current language once. */
|
|
90
|
+
export function fixAgentName(store: Pick<Store, "getSetting" | "setSetting">): void {
|
|
91
|
+
if (!ownAgentName(store.getSetting(AGENT_NAME_SETTING) ?? "")) store.setSetting(AGENT_NAME_SETTING, defaultAgentName());
|
|
92
|
+
}
|
|
93
|
+
|
|
86
94
|
export function saveProfile(store: Pick<Store, "getSetting" | "setSetting">, body: unknown): ProfileSave {
|
|
87
95
|
if (typeof body !== "object" || body === null) return { error: "nickname_required" };
|
|
88
96
|
const { nickname, agentName } = body as { nickname?: unknown; agentName?: unknown };
|
|
89
97
|
if (nickname === undefined && agentName === undefined) return { error: "nickname_required" };
|
|
90
98
|
if (nickname !== undefined && typeof nickname !== "string") return { error: "nickname_required" };
|
|
91
99
|
if (agentName !== undefined && typeof agentName !== "string") return { error: "agent_name_required" };
|
|
92
|
-
const name = agentName === undefined ? undefined :
|
|
100
|
+
const name = agentName === undefined ? undefined : normalizeAgentName(agentName);
|
|
93
101
|
if (name !== undefined && displayWidth(name) > AGENT_NAME_WIDTH) return { error: "agent_name_too_long" };
|
|
94
102
|
if (nickname !== undefined) store.setSetting(NICKNAME_SETTING, ownNickname(nickname));
|
|
95
103
|
if (name !== undefined) store.setSetting(AGENT_NAME_SETTING, name);
|
package/src/security/baseline.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync,
|
|
5
5
|
} from "node:fs";
|
|
6
6
|
import { securityBaselinePaths, type Env, type RuntimePaths } from "../runtime/paths.ts";
|
|
7
|
+
import { machineTerms } from "./terms.ts";
|
|
7
8
|
|
|
8
9
|
export const SCHEMA_VERSION = 1;
|
|
9
10
|
|
|
@@ -85,7 +86,8 @@ export function parseBaseline(text: string): BaselineResult {
|
|
|
85
86
|
}
|
|
86
87
|
allow.push({ value: entry.value, scopes: entry.scopes });
|
|
87
88
|
}
|
|
88
|
-
|
|
89
|
+
// Bug 268: the automatic account term alone is a usable baseline; reject only when no term of either kind exists.
|
|
90
|
+
if (input.terms.length === 0 && machineTerms({ home: input.home, user: input.user }).terms.length === 0) return { ok: false, error: "empty" };
|
|
89
91
|
return { ok: true, file: {
|
|
90
92
|
schemaVersion: input.schemaVersion, baselineId: input.baselineId, revision: input.revision,
|
|
91
93
|
terms: input.terms, home: input.home, user: input.user, allow,
|
package/src/usage.ts
CHANGED
|
@@ -26,6 +26,7 @@ const USAGE_TEXT = {
|
|
|
26
26
|
reset: { en: " (resets {time})", ko: " (초기화 {time})" },
|
|
27
27
|
lastUsed: { en: " · Last used {time}", ko: " · 최근 사용 {time}" },
|
|
28
28
|
failed: { en: "Lookup failed ({detail})", ko: "조회 실패 ({detail})" },
|
|
29
|
+
limited: { en: " · Limit reached ({detail})", ko: " · 한도 도달 ({detail})" },
|
|
29
30
|
unknown: { en: "Unknown", ko: "알 수 없음" },
|
|
30
31
|
expired: { en: "Token expired (the engine will refresh it on next use)", ko: "토큰 만료 (엔진이 다음 사용 때 갱신)" },
|
|
31
32
|
unsupported: { en: "Usage API unavailable", ko: "사용량 API 없음" },
|
|
@@ -69,7 +70,8 @@ export interface UsageRow {
|
|
|
69
70
|
readonly provider: string;
|
|
70
71
|
readonly slot: string;
|
|
71
72
|
readonly pinned: boolean;
|
|
72
|
-
|
|
73
|
+
/** limited: HTTP 429; the windows are the last ones seen for this slot, as omo-usage keeps them. */
|
|
74
|
+
readonly status: "ok" | "limited" | "failed" | "expired" | "unsupported";
|
|
73
75
|
/** Why a lookup failed: an HTTP status or a fixed phrase, never a server message. */
|
|
74
76
|
readonly detail?: string;
|
|
75
77
|
readonly windows: readonly UsageWindow[];
|
|
@@ -280,7 +282,7 @@ async function lookup(account: Account, now: number, deps: UsageDeps): Promise<U
|
|
|
280
282
|
redirect: "error",
|
|
281
283
|
signal: AbortSignal.timeout(deps.timeoutMs ?? 10_000),
|
|
282
284
|
});
|
|
283
|
-
if (!response.ok) return { ...base, status: "failed", detail: say(USAGE_TEXT.http, { status: response.status }) };
|
|
285
|
+
if (!response.ok) return { ...base, status: response.status === 429 ? "limited" : "failed", detail: say(USAGE_TEXT.http, { status: response.status }) };
|
|
284
286
|
const windows = windowsOf(account.kind, await response.json().catch(() => undefined), now);
|
|
285
287
|
return windows.length > 0 ? { ...base, status: "ok", windows } : { ...base, status: "failed", detail: say(USAGE_TEXT.noUsage) };
|
|
286
288
|
} catch (error) {
|
|
@@ -289,11 +291,18 @@ async function lookup(account: Account, now: number, deps: UsageDeps): Promise<U
|
|
|
289
291
|
}
|
|
290
292
|
}
|
|
291
293
|
|
|
292
|
-
|
|
294
|
+
/** `last` holds each slot's newest windows (key provider/name); a 429 shows them, and without them the slot failed. */
|
|
295
|
+
export async function collectUsage(auth: unknown, poolState: unknown, now: number, deps: UsageDeps = {}, last = new Map<string, readonly UsageWindow[]>()): Promise<UsageRow[]> {
|
|
293
296
|
const used = lastUsed(poolState);
|
|
294
297
|
return Promise.all(readAccounts(auth).map(async (account) => {
|
|
295
|
-
const
|
|
296
|
-
|
|
298
|
+
const key = `${account.provider}/${account.name}`;
|
|
299
|
+
let row = await lookup(account, now, deps);
|
|
300
|
+
if (row.status === "ok") last.set(key, row.windows);
|
|
301
|
+
if (row.status === "limited") {
|
|
302
|
+
const windows = last.get(key);
|
|
303
|
+
row = windows ? { ...row, windows } : { ...row, status: "failed" };
|
|
304
|
+
}
|
|
305
|
+
const at = used.get(key);
|
|
297
306
|
return at === undefined ? row : { ...row, lastUsedAt: at };
|
|
298
307
|
}));
|
|
299
308
|
}
|
|
@@ -310,8 +319,9 @@ export function formatUsageReport(rows: readonly UsageRow[], timeZone?: string):
|
|
|
310
319
|
for (const row of rows) {
|
|
311
320
|
const name = say(USAGE_TEXT.account, { slot: row.slot, provider: row.provider, pinned: row.pinned ? say(USAGE_TEXT.pinned) : "" });
|
|
312
321
|
let body: string;
|
|
313
|
-
if (row.status === "ok") {
|
|
322
|
+
if (row.status === "ok" || row.status === "limited") {
|
|
314
323
|
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(" · ");
|
|
324
|
+
if (row.status === "limited") body += say(USAGE_TEXT.limited, { detail: row.detail ?? say(USAGE_TEXT.unknown) });
|
|
315
325
|
} else if (row.status === "failed") body = say(USAGE_TEXT.failed, { detail: row.detail ?? say(USAGE_TEXT.unknown) });
|
|
316
326
|
else body = say(USAGE_TEXT[row.status]);
|
|
317
327
|
const recent = row.lastUsedAt === undefined ? "" : say(USAGE_TEXT.lastUsed, { time: stamp(row.lastUsedAt, timeZone) });
|
|
@@ -340,13 +350,14 @@ export function createUsageReport(deps: UsageDeps & {
|
|
|
340
350
|
now?: () => number;
|
|
341
351
|
timeZone?: string;
|
|
342
352
|
}): () => Promise<string> {
|
|
353
|
+
const last = new Map<string, readonly UsageWindow[]>();
|
|
343
354
|
return async () => {
|
|
344
355
|
if (deps.inContainer) return `${say(USAGE_TEXT.title)}\n${say(USAGE_UNAVAILABLE)}`;
|
|
345
356
|
const auth = readJson(join(deps.dir, "auth.json"));
|
|
346
357
|
if ("missing" in auth) return `${say(USAGE_TEXT.title)}\n${say(USAGE_TEXT.emptyAuth)}`;
|
|
347
358
|
if ("broken" in auth) return `${say(USAGE_TEXT.title)}\n${say(USAGE_TEXT.brokenAuth)}`;
|
|
348
359
|
const pool = readJson(join(deps.dir, "credential-pool-state.json"));
|
|
349
|
-
const rows = await collectUsage(auth.value, "value" in pool ? pool.value : null, (deps.now ?? Date.now)(), deps);
|
|
360
|
+
const rows = await collectUsage(auth.value, "value" in pool ? pool.value : null, (deps.now ?? Date.now)(), deps, last);
|
|
350
361
|
return formatUsageReport(rows, deps.timeZone);
|
|
351
362
|
};
|
|
352
363
|
}
|
package/web/DESIGN.md
CHANGED
|
@@ -49,7 +49,8 @@ former 72px rail, widened by 200px on 2026-10-04 for the 작업 상태 drawing;
|
|
|
49
49
|
below 1280px, 200px at 900px and below). The profile block is split in two halves. The left half shows the owner's nickname (double-click edits it:
|
|
50
50
|
Enter or leaving saves, Esc cancels); the gateway keeps it (`GET/PUT /api/profile`, `profile.updated`) and the
|
|
51
51
|
same name labels the user's chat messages, their avatar letter and the 작업 상태 user card. The right half shows the
|
|
52
|
-
agent's (gateway main) name with the Zig icon and the same editing (`agentName` in the same API; default "지그"
|
|
52
|
+
agent's (gateway main) name with the Zig icon and the same editing (`agentName` in the same API; the gateway stores the default "지그" or "Zig" of its language once at first start, and the
|
|
53
|
+
name is never translated afterwards (bug 271); at most
|
|
53
54
|
20 display-width units, Hangul/CJK 2 and ASCII 1; a name too wide stays red and is not saved). The main session reads
|
|
54
55
|
the name at the start of each turn, and the 작업 상태 hub shows it when WebGL is unavailable. The conversation list keeps at least 140px; the 작업 상태 drawing shrinks before it does.
|
|
55
56
|
|
package/web/profile.js
CHANGED
|
@@ -30,9 +30,9 @@ function ownNickname(value) {
|
|
|
30
30
|
return isBuiltIn(name, "profile.nickname.default") ? "" : name;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** The agent name as saved: whitespace collapsed and trimmed; "" for blank. Not translated (bug 271), "Zig" and "지그" included. */
|
|
33
34
|
function ownAgentName(value) {
|
|
34
|
-
|
|
35
|
-
return isBuiltIn(name, "profile.agent.default") ? "" : name;
|
|
35
|
+
return typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
/** The nickname to show: the user's own, else the current language's default. */
|
|
@@ -71,8 +71,9 @@ export function readAgentName(storage) {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
export function writeAgentName(storage, value) {
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
const name = normalizeAgentName(value);
|
|
75
|
+
storage.setItem(AGENT_NAME_KEY, name);
|
|
76
|
+
return name;
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
/**
|