zigmini 0.9.0-beta.3 → 1.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +36 -4
- package/README.md +36 -4
- 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 +60 -11
- package/docs/manual.md +61 -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/task-control.ts +26 -16
- 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 +97 -13
- package/src/approvals.ts +6 -1
- package/src/cli/doctor.ts +7 -0
- package/src/cli/main.ts +38 -0
- package/src/cli/messages.ts +10 -2
- package/src/cli/rotate-main.ts +2 -1
- package/src/cli/rules.ts +70 -0
- 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 +339 -79
- package/src/main.ts +95 -9
- package/src/maintenance.ts +1 -1
- package/src/manual.ts +9 -2
- 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 +172 -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
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { runtimePaths, type Env } from "../runtime/paths.ts";
|
|
5
|
+
import { say, type Text } from "../server-text.ts";
|
|
6
|
+
import { parseAgentDefinition, AgentDefinitionError, serializeAgentDefinition, type AgentDefinition } from "./definition.ts";
|
|
7
|
+
import { migrateLegacyAgentFiles } from "./legacy-files.ts";
|
|
8
|
+
|
|
9
|
+
export function agentTrashEntry(id: string, timestamp: string): string {
|
|
10
|
+
return `${id}-${timestamp}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Trash names retain the UTC ISO timestamp used by the file store, including colons. */
|
|
14
|
+
export function agentTrashTimestamp(entry: string): number | null {
|
|
15
|
+
const timestamp = /-(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)$/.exec(entry)?.[1];
|
|
16
|
+
return timestamp === undefined ? null : Date.parse(timestamp);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A broken entry must not prevent the other agents from being listed. */
|
|
20
|
+
export function unreadableAgent(error: unknown): boolean {
|
|
21
|
+
return error instanceof AgentDefinitionError || error instanceof AgentFileError
|
|
22
|
+
|| (error instanceof Error && "code" in error
|
|
23
|
+
&& ["ENOENT", "EACCES", "EPERM", "ENOTDIR", "EISDIR", "ELOOP"].includes(String(error.code)));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const COPY_SUFFIX: Text = { en: " copy", ko: " 사본" };
|
|
27
|
+
|
|
28
|
+
export class AgentFileError extends Error {
|
|
29
|
+
constructor(readonly code: "path" | "exists" | "id", message: string) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = "AgentFileError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Synchronous file operations keep each gateway mutation uninterrupted by another request. */
|
|
36
|
+
export class AgentFileStore {
|
|
37
|
+
readonly root: string;
|
|
38
|
+
private lastTime = 0;
|
|
39
|
+
|
|
40
|
+
constructor(env: Env = process.env) {
|
|
41
|
+
const root = resolve(runtimePaths(env).agents);
|
|
42
|
+
mkdirSync(root, { recursive: true });
|
|
43
|
+
if (lstatSync(root).isSymbolicLink()) throw new AgentFileError("path", "Agent root cannot be a symlink");
|
|
44
|
+
this.root = realpathSync(root);
|
|
45
|
+
migrateLegacyAgentFiles(this.root);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Check existing ancestors too, so a missing child cannot bypass a symlink check. */
|
|
49
|
+
private checked(path: string): string {
|
|
50
|
+
const target = resolve(this.root, path);
|
|
51
|
+
const inside = (candidate: string): boolean => {
|
|
52
|
+
const rel = relative(this.root, candidate);
|
|
53
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
54
|
+
};
|
|
55
|
+
if (!inside(target)) throw new AgentFileError("path", "Path escapes agent root");
|
|
56
|
+
let existing = target;
|
|
57
|
+
while (!existsSync(existing)) {
|
|
58
|
+
// Dangling symlinks must not be mistaken for new files.
|
|
59
|
+
try {
|
|
60
|
+
if (lstatSync(existing).isSymbolicLink()) throw new AgentFileError("path", "Dangling agent symlink");
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
|
|
63
|
+
}
|
|
64
|
+
existing = dirname(existing);
|
|
65
|
+
}
|
|
66
|
+
if (!inside(realpathSync(existing))) throw new AgentFileError("path", "Symlink escapes agent root");
|
|
67
|
+
return target;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private folder(id: string): string {
|
|
71
|
+
if (!/^[a-f0-9]{8}$/.test(id)) throw new AgentFileError("id", "Invalid agent id");
|
|
72
|
+
return this.checked(id);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private timestamp(): string {
|
|
76
|
+
this.lastTime = Math.max(Date.now(), this.lastTime + 1);
|
|
77
|
+
return new Date(this.lastTime).toISOString();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
create(source: string): string {
|
|
81
|
+
parseAgentDefinition(source);
|
|
82
|
+
const id = randomUUID().slice(0, 8);
|
|
83
|
+
const folder = this.folder(id);
|
|
84
|
+
mkdirSync(folder);
|
|
85
|
+
mkdirSync(join(folder, "knowledge/source"), { recursive: true });
|
|
86
|
+
mkdirSync(join(folder, "knowledge/notes/daily"), { recursive: true });
|
|
87
|
+
writeFileSync(join(folder, "knowledge/MEMORY.md"), "", { flag: "wx" });
|
|
88
|
+
writeFileSync(join(folder, "agent.md"), source, { flag: "wx" });
|
|
89
|
+
return id;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
read(id: string): AgentDefinition {
|
|
93
|
+
return parseAgentDefinition(readFileSync(this.checked(join(this.folder(id), "agent.md")), "utf8"));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
list(): readonly { readonly id: string; readonly definition: AgentDefinition }[] {
|
|
97
|
+
return readdirSync(this.root, { withFileTypes: true })
|
|
98
|
+
.filter((entry) => !entry.name.startsWith(".") && (entry.isDirectory() || entry.isSymbolicLink()))
|
|
99
|
+
.flatMap((entry) => {
|
|
100
|
+
try {
|
|
101
|
+
return [{ id: entry.name, definition: this.read(entry.name) }];
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (unreadableAgent(error)) return [];
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private saveFile(path: string, source: string): void {
|
|
110
|
+
const file = this.checked(path);
|
|
111
|
+
const history = this.checked(join(dirname(file), file === join(this.root, "defaults.md") ? ".history" : "history"));
|
|
112
|
+
if (existsSync(file)) {
|
|
113
|
+
mkdirSync(history, { recursive: true });
|
|
114
|
+
let previous = this.checked(join(history, `${this.timestamp()}.md`));
|
|
115
|
+
while (existsSync(previous)) previous = this.checked(join(history, `${this.timestamp()}.md`));
|
|
116
|
+
copyFileSync(file, previous);
|
|
117
|
+
}
|
|
118
|
+
writeFileSync(file, source);
|
|
119
|
+
if (existsSync(history)) {
|
|
120
|
+
const entries = readdirSync(history).filter((name) => /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z\.md$/.test(name)).sort().reverse();
|
|
121
|
+
for (const name of entries.slice(20)) unlinkSync(this.checked(join(history, name)));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
save(id: string, source: string): void {
|
|
126
|
+
parseAgentDefinition(source);
|
|
127
|
+
this.saveFile(join(this.folder(id), "agent.md"), source);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
readDefaults(): AgentDefinition | undefined {
|
|
131
|
+
const file = this.checked("defaults.md");
|
|
132
|
+
return existsSync(file) ? parseAgentDefinition(readFileSync(file, "utf8"), "defaults") : undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
saveDefaults(source: string): void {
|
|
136
|
+
parseAgentDefinition(source, "defaults");
|
|
137
|
+
this.saveFile(join(this.root, "defaults.md"), source);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Clone only regular source files and directories; never carry symlinks into the new agent. */
|
|
141
|
+
private copy(source: string, target: string): void {
|
|
142
|
+
const file = this.checked(source);
|
|
143
|
+
const destination = this.checked(target);
|
|
144
|
+
const stat = lstatSync(file);
|
|
145
|
+
if (stat.isSymbolicLink()) throw new AgentFileError("path", "Cannot clone a symlink");
|
|
146
|
+
if (stat.isDirectory()) {
|
|
147
|
+
mkdirSync(destination, { recursive: true });
|
|
148
|
+
for (const name of readdirSync(file)) this.copy(join(file, name), join(destination, name));
|
|
149
|
+
} else {
|
|
150
|
+
copyFileSync(file, destination);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
clone(id: string): string {
|
|
155
|
+
const definition = this.read(id);
|
|
156
|
+
const original = this.folder(id);
|
|
157
|
+
// Validate the copy set before allocating the new agent.
|
|
158
|
+
const validate = (path: string): void => {
|
|
159
|
+
const stat = lstatSync(this.checked(path));
|
|
160
|
+
if (stat.isSymbolicLink()) throw new AgentFileError("path", "Cannot clone a symlink");
|
|
161
|
+
if (stat.isDirectory()) for (const name of readdirSync(path)) validate(join(path, name));
|
|
162
|
+
};
|
|
163
|
+
validate(join(original, "knowledge/source"));
|
|
164
|
+
validate(join(original, "knowledge/MEMORY.md"));
|
|
165
|
+
const cloned = this.create(serializeAgentDefinition({
|
|
166
|
+
...definition, fields: { ...definition.fields, name: `${definition.fields.name}${say(COPY_SUFFIX)}` },
|
|
167
|
+
}));
|
|
168
|
+
this.copy(join(original, "knowledge/source"), join(this.folder(cloned), "knowledge/source"));
|
|
169
|
+
this.copy(join(original, "knowledge/MEMORY.md"), join(this.folder(cloned), "knowledge/MEMORY.md"));
|
|
170
|
+
return cloned;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
trash(id: string): string {
|
|
174
|
+
const source = this.folder(id);
|
|
175
|
+
const trash = this.checked(".trash");
|
|
176
|
+
mkdirSync(trash, { recursive: true });
|
|
177
|
+
let entry = agentTrashEntry(id, this.timestamp());
|
|
178
|
+
while (existsSync(this.checked(join(trash, entry)))) entry = agentTrashEntry(id, this.timestamp());
|
|
179
|
+
renameSync(source, this.checked(join(trash, entry)));
|
|
180
|
+
return entry;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
restore(entry: string): string {
|
|
184
|
+
const match = /^([a-f0-9]{8})-\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.exec(entry);
|
|
185
|
+
const id = match?.[1];
|
|
186
|
+
if (!id) throw new AgentFileError("id", "Invalid trash entry");
|
|
187
|
+
const target = this.folder(id);
|
|
188
|
+
if (existsSync(target)) throw new AgentFileError("exists", "Agent id already exists");
|
|
189
|
+
renameSync(this.checked(join(".trash", entry)), target);
|
|
190
|
+
return id;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { lstatSync, mkdirSync, readdirSync, renameSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Move only unclaimed legacy entries; keep collisions and all file bytes intact. */
|
|
5
|
+
export function migrateLegacyAgentFiles(root: string): void {
|
|
6
|
+
const legacy = join(dirname(root), "personas");
|
|
7
|
+
if (!readdirSync(dirname(root)).includes("personas")) return;
|
|
8
|
+
if (!lstatSync(legacy).isDirectory()) {
|
|
9
|
+
console.warn("agent_legacy_files_skipped", { count: 1 });
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
let collisions = 0;
|
|
13
|
+
const move = (source: string, target: string): void => {
|
|
14
|
+
if (readdirSync(dirname(target)).includes(basename(target))) {
|
|
15
|
+
collisions++;
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
renameSync(source, target);
|
|
19
|
+
if (lstatSync(target).isDirectory()) {
|
|
20
|
+
const names = readdirSync(target);
|
|
21
|
+
if (names.includes("persona.md")) {
|
|
22
|
+
if (names.includes("agent.md")) collisions++;
|
|
23
|
+
else renameSync(join(target, "persona.md"), join(target, "agent.md"));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
for (const entry of readdirSync(legacy, { withFileTypes: true })) {
|
|
28
|
+
const source = join(legacy, entry.name);
|
|
29
|
+
const target = join(root, entry.name);
|
|
30
|
+
if ((entry.name === ".history" || entry.name === ".trash") && entry.isDirectory()) {
|
|
31
|
+
if (readdirSync(root).includes(entry.name) && !lstatSync(target).isDirectory()) {
|
|
32
|
+
collisions++;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
mkdirSync(target, { recursive: true });
|
|
36
|
+
for (const child of readdirSync(source, { withFileTypes: true })) {
|
|
37
|
+
if (child.isSymbolicLink()) { collisions++; continue; }
|
|
38
|
+
move(join(source, child.name), join(target, child.name));
|
|
39
|
+
}
|
|
40
|
+
} else if ((entry.name === "defaults.md" && entry.isFile())
|
|
41
|
+
|| (/^[a-f0-9]{8}$/.test(entry.name) && entry.isDirectory())) {
|
|
42
|
+
move(source, target);
|
|
43
|
+
} else collisions++;
|
|
44
|
+
}
|
|
45
|
+
if (collisions > 0) console.warn("agent_legacy_files_collisions", { count: collisions });
|
|
46
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AgentDefinition } from "./definition.ts";
|
|
2
|
+
|
|
3
|
+
export interface AgentListEntry { readonly name: string; readonly description: string; readonly link: string }
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The read-only list main sees: name, one-line purpose and the gateway-relative web link of the conversation.
|
|
7
|
+
* `visible` holds only non-hidden conversations, so a trashed agent (hidden conversation) never appears.
|
|
8
|
+
* Definition body and knowledge are never part of the result.
|
|
9
|
+
*/
|
|
10
|
+
export function agentList(
|
|
11
|
+
agents: readonly { readonly id: string; readonly definition: AgentDefinition }[],
|
|
12
|
+
visible: readonly { readonly id: string; readonly agentId?: string | null }[],
|
|
13
|
+
): AgentListEntry[] {
|
|
14
|
+
const entries: AgentListEntry[] = [];
|
|
15
|
+
for (const { id, definition } of agents) {
|
|
16
|
+
const conv = visible.find((row) => row.agentId === id);
|
|
17
|
+
if (!conv) continue;
|
|
18
|
+
entries.push({ name: String(definition.fields.name), description: String(definition.fields.description), link: `/#conv=${encodeURIComponent(conv.id)}` });
|
|
19
|
+
}
|
|
20
|
+
return entries;
|
|
21
|
+
}
|
package/src/agent-rules.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { type Env, runtimeHome } from "./runtime/paths.ts";
|
|
4
|
+
import { type Env, runtimeHome, runtimePaths } from "./runtime/paths.ts";
|
|
5
|
+
import { readVoiceSetting } from "./voice-setting.ts";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Improvement 117: the agent rules the bridge appends to the main and work session instructions. The package file
|
|
@@ -18,38 +19,121 @@ export const INSTALL_RULES_TEMPLATE = `<!--
|
|
|
18
19
|
이 파일은 이 설치에만 쓰는 에이전트 규칙입니다. zigmini 업데이트는 이 파일을 바꾸지 않습니다.
|
|
19
20
|
여기에 쓴 내용은 게이트웨이가 main과 작업 세션의 지시문 뒤에 붙입니다. 패키지 규칙과 부딪치면 이 파일이 이깁니다.
|
|
20
21
|
새 세션부터 적용됩니다(새 대화, 세션 교체, 브리지 재시작, 업데이트). 이미 열린 세션은 이전 내용을 씁니다.
|
|
22
|
+
공통 규칙 항목을 통째로 바꾸려면 이 파일의 항목 안에 replaces:<공통 ID>를 HTML 주석으로 적습니다(여는 표시 <!-- 바로 뒤에 replaces:ID, 그 뒤에 닫는 표시).
|
|
23
|
+
공통 항목의 번호·ID·첫 줄은 zigmini rules, 이 파일의 대체 상태는 zigmini rules check로 봅니다.
|
|
24
|
+
ID가 없는 항목은 새 규칙으로 뒤에 붙습니다. 공통에 없는 ID를 적으면 새 규칙으로 붙고 doctor가 경고합니다.
|
|
21
25
|
최대 32KB입니다. 더 크면 게이트웨이는 이 파일을 쓰지 않습니다. 이런 주석은 지시문에 붙지 않습니다.
|
|
22
26
|
-->
|
|
23
27
|
`;
|
|
24
28
|
|
|
25
29
|
export type RulesStatus = "used" | "missing" | "empty" | "too_large" | "unreadable";
|
|
26
30
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
/** Improvement 250: the package rule text between these markers is the repository security check duty. */
|
|
32
|
+
export const REPO_SECURITY_SECTION = /<!--repo-security-check-->[\s\S]*?<!--\/repo-security-check-->/g;
|
|
33
|
+
|
|
34
|
+
/** What to drop from the package rules: the repository security check duty while that switch is off. */
|
|
35
|
+
export function packageRulesDrop(env: Env = process.env): RegExp | undefined {
|
|
36
|
+
return readVoiceSetting(runtimePaths(env).repoSecurityCheckFile) ? undefined : REPO_SECURITY_SECTION;
|
|
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: "" };
|
|
30
72
|
try {
|
|
31
|
-
if (statSync(path).size > AGENT_RULES_MAX_BYTES) return { status: "too_large",
|
|
32
|
-
raw
|
|
73
|
+
if (statSync(path).size > AGENT_RULES_MAX_BYTES) return { status: "too_large", raw: "" };
|
|
74
|
+
return { status: "used", raw: readFileSync(path, "utf8") };
|
|
33
75
|
} catch {
|
|
34
|
-
return { status: "unreadable",
|
|
76
|
+
return { status: "unreadable", raw: "" };
|
|
35
77
|
}
|
|
36
|
-
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function finish(status: RulesStatus, raw: string, drop?: RegExp): { status: RulesStatus; text: string } {
|
|
81
|
+
if (status !== "used") return { status, text: "" };
|
|
82
|
+
const text = (drop ? raw.replace(drop, "") : raw).replace(/<!--[\s\S]*?-->/g, "").trim();
|
|
37
83
|
return text === "" ? { status: "empty", text } : { status: "used", text };
|
|
38
84
|
}
|
|
39
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
|
+
|
|
40
117
|
/** The text to append after the role instructions, and one log line that names the files but never their content. */
|
|
41
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!));
|
|
42
124
|
const sources = [
|
|
43
|
-
{ name: "package", path: packageFile, heading: PACKAGE_RULES_HEADING },
|
|
44
|
-
{ 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) },
|
|
45
127
|
];
|
|
46
128
|
const parts: string[] = [];
|
|
47
129
|
const notes: string[] = [];
|
|
48
130
|
for (const source of sources) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
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}`);
|
|
52
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(",")}`);
|
|
53
137
|
return { text: parts.join("\n\n"), log: `agent rules: ${notes.join(" ")}` };
|
|
54
138
|
}
|
|
55
139
|
|
package/src/approvals.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { say, type Text } from "./server-text.ts";
|
|
2
|
+
|
|
3
|
+
/** Improvement 253: the cancel reason the web and app show on a dialog whose deadline passed. */
|
|
4
|
+
export const DIALOG_TIMEOUT_REASON: Text = { en: "Cancelled automatically because the deadline passed.", ko: "기한이 지나 자동으로 취소했습니다." };
|
|
5
|
+
|
|
1
6
|
export type DialogMethod = "select" | "confirm" | "input" | "editor";
|
|
2
7
|
|
|
3
8
|
export interface DialogRequest {
|
|
@@ -41,7 +46,7 @@ export class DialogGate {
|
|
|
41
46
|
const createdAt = Date.now();
|
|
42
47
|
const full: DialogRequest = { ...request, createdAt, deadlineAt: createdAt + this.timeoutMs };
|
|
43
48
|
const timer = setTimeout(() => {
|
|
44
|
-
this.settle({ actionId: full.actionId, answer: { kind: "cancel", reason:
|
|
49
|
+
this.settle({ actionId: full.actionId, answer: { kind: "cancel", reason: say(DIALOG_TIMEOUT_REASON) }, by: "timeout" });
|
|
45
50
|
}, this.timeoutMs);
|
|
46
51
|
this.pending.set(full.actionId, { request: full, resolve: onResolved, timer });
|
|
47
52
|
return full;
|
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";
|
|
@@ -13,6 +14,11 @@ import { handoffCommand } from "../update/handoff-system.ts";
|
|
|
13
14
|
import { HANDOFF_COMMAND } from "../update/strategy.ts";
|
|
14
15
|
import { ROTATE_COMMAND, ROTATE_RUN_COMMAND, rotateCommand } from "./rotate-main.ts";
|
|
15
16
|
import { EXIT_USAGE, parseMigrateArgs, parseMigrateRunArgs, runMigrate, runMigrateRun, systemMigrateDeps, systemMigrateRunDeps } from "./migrate.ts";
|
|
17
|
+
import { parseImportArgs, runImport, systemImportDeps } from "./security-import.ts";
|
|
18
|
+
import { parseScanArgs, prepareScan, reportScan, resolveScanBaseline, runScan, scanBaselinePaths, scanRepository, syncHashes,
|
|
19
|
+
type ResolvedScanBaseline, type ScanResult } from "./scan.ts";
|
|
20
|
+
import { SCAN_DISABLED_EXIT, scanDisabledNotice } from "../repo-security-setting.ts";
|
|
21
|
+
import { probeRevision } from "../security/revision-probe.ts";
|
|
16
22
|
|
|
17
23
|
export const HELP = message("ko", "help");
|
|
18
24
|
|
|
@@ -59,6 +65,34 @@ export async function runCommand(argv: readonly string[], deps: LaunchdDeps, ctx
|
|
|
59
65
|
if (import.meta.main) {
|
|
60
66
|
const argv = process.argv.slice(2);
|
|
61
67
|
if (argv[0] === "setup") process.exit(await setupCommand(argv.slice(1), () => systemSetupDeps(packageVersion()), HELP));
|
|
68
|
+
if (argv[0] === "security") {
|
|
69
|
+
const options = argv[1] === "import-baseline" ? parseImportArgs(argv.slice(2)) : "zigmini security import-baseline [--from <file>] [--dry-run]";
|
|
70
|
+
if (typeof options === "string") {
|
|
71
|
+
process.stderr.write(`${options}\n\n${HELP}`);
|
|
72
|
+
process.exit(2);
|
|
73
|
+
}
|
|
74
|
+
process.exit(await runImport(options, systemImportDeps()));
|
|
75
|
+
}
|
|
76
|
+
if (argv[0] === "scan") {
|
|
77
|
+
const deps = systemDeps();
|
|
78
|
+
const ctx = systemContext();
|
|
79
|
+
const lang = resolveLanguage(ctx.paths.home, ctx.env);
|
|
80
|
+
const disabled = scanDisabledNotice(ctx.paths.repoSecurityCheckFile, argv.slice(1), lang);
|
|
81
|
+
if (disabled !== null) {
|
|
82
|
+
deps.err(`${disabled}\n`);
|
|
83
|
+
process.exit(SCAN_DISABLED_EXIT);
|
|
84
|
+
}
|
|
85
|
+
const options = parseScanArgs(argv.slice(1), process.cwd(), lang);
|
|
86
|
+
process.exit(await runScan<ResolvedScanBaseline, ScanResult>(options, {
|
|
87
|
+
lang, out: deps.out, err: deps.err,
|
|
88
|
+
prepare: (scanOptions) => prepareScan(scanOptions, scanBaselinePaths(scanOptions, ctx.env)),
|
|
89
|
+
baseline: (scanOptions) => resolveScanBaseline(scanOptions, { env: ctx.env, lang, err: deps.err }),
|
|
90
|
+
revision: (baseline) => probeRevision(baseline, { env: ctx.env }),
|
|
91
|
+
scan: scanRepository,
|
|
92
|
+
syncHashes,
|
|
93
|
+
report: (report, scanOptions, result) => reportScan(report, scanOptions, result, deps.out),
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
62
96
|
if (argv[0] === "migrate" || argv[0] === "migrate-run") {
|
|
63
97
|
const home = process.env.HOME || "";
|
|
64
98
|
const options = argv[0] === "migrate" ? parseMigrateArgs(argv.slice(1), home) : parseMigrateRunArgs(argv.slice(1), home);
|
|
@@ -74,6 +108,10 @@ if (import.meta.main) {
|
|
|
74
108
|
const notice = () => loadNotice();
|
|
75
109
|
const hint = setupHint(argv[0], ctx.paths.home, kind);
|
|
76
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
|
+
}
|
|
77
115
|
if (argv[0] === "consent") {
|
|
78
116
|
if (argv.length !== 1) {
|
|
79
117
|
deps.err(`잘못된 인자: ${argv.join(" ")}\n\n${HELP}`);
|
package/src/cli/messages.ts
CHANGED
|
@@ -7,8 +7,8 @@ export type Language = "en" | "ko";
|
|
|
7
7
|
export const LANGUAGE_FILE = "language";
|
|
8
8
|
export const LANGUAGE_QUESTION = "Language / 언어 [1] English [2] 한국어: ";
|
|
9
9
|
|
|
10
|
-
|
|
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 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 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" },
|
|
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 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} 에 값이 필요합니다" },
|
|
@@ -117,6 +117,14 @@ export const MESSAGES = {
|
|
|
117
117
|
schemaRefused: { en: "The bridge was not started. Gateway health ({url}) reports omo=schema_too_new.\n", ko: "브리지를 시작하지 않았습니다. 게이트웨이 health({url})가 omo=schema_too_new를 보입니다.\n" },
|
|
118
118
|
} as const satisfies Record<string, Readonly<Record<Language, string>>>;
|
|
119
119
|
|
|
120
|
+
export const MESSAGES = {
|
|
121
|
+
...BASE_MESSAGES,
|
|
122
|
+
help: {
|
|
123
|
+
en: BASE_MESSAGES.help.en.replace("Commands:\n", "Commands:\n scan [<repo>] Scan a repository for security findings\n"),
|
|
124
|
+
ko: BASE_MESSAGES.help.ko.replace("명령:\n", "명령:\n scan [<repo>] 저장소의 보안 발견 항목을 검사합니다\n"),
|
|
125
|
+
},
|
|
126
|
+
} as const satisfies Record<string, Readonly<Record<Language, string>>>;
|
|
127
|
+
|
|
120
128
|
export type MessageKey = keyof typeof MESSAGES;
|
|
121
129
|
export function message(lang: Language, key: MessageKey, values: Readonly<Record<string, string | number>> = {}): string {
|
|
122
130
|
return MESSAGES[key][lang].replace(/\{(\w+)\}/g, (placeholder, name: string) => String(values[name] ?? placeholder));
|
package/src/cli/rotate-main.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from
|
|
|
3
3
|
import { basename, dirname, join } from "node:path";
|
|
4
4
|
import { agentDir } from "../model-accounts.ts";
|
|
5
5
|
import { ROTATION_HELD_MESSAGE } from "../protocol.ts";
|
|
6
|
+
import { isTextOf } from "../server-text.ts";
|
|
6
7
|
import { type LaunchdContext, type LaunchdDeps, ownProcess, parseConfigEnv, startCommand, stopCommand } from "./launchd.ts";
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -297,7 +298,7 @@ export function socketRotate(url: string, timeoutMs: number): Promise<RotateOutc
|
|
|
297
298
|
end({ kind: "rotated", before, from: data.from, to: data.to });
|
|
298
299
|
} else if (message.type === "error") {
|
|
299
300
|
const text = typeof data.message === "string" ? data.message : JSON.stringify(data);
|
|
300
|
-
end(text
|
|
301
|
+
end(isTextOf(text, ROTATION_HELD_MESSAGE) ? { kind: "held", message: text } : { kind: "error", message: text });
|
|
301
302
|
}
|
|
302
303
|
};
|
|
303
304
|
ws.onerror = () => end({ kind: "error", message: "WebSocket 연결 오류" });
|