zigmini 0.8.0-beta.3 → 0.8.0-beta.5
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 +267 -0
- package/README.md +155 -129
- package/assets/agent/gateway-rules.md +1 -0
- 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 +382 -0
- package/docs/manual.md +382 -0
- package/host/omo-bridge/continuity-control.ts +5 -26
- package/host/omo-check/patches/omo-5.1.35-gateway-port.manifest.json +12 -4
- package/host/omo-check/patches/omo-5.1.35-image-replay.patch +45 -0
- package/host/omo-check/patches/omo-5.1.35-question-capability.patch +65 -0
- package/package.json +5 -2
- package/src/cli/doctor.ts +5 -5
- package/src/cli/main.ts +6 -1
- package/src/cli/setup-hint.ts +25 -0
- package/src/engine/schema-guard.ts +1 -1
- package/src/hub.ts +203 -52
- package/src/main.ts +7 -8
- package/src/manual.ts +31 -0
- package/src/omo/generations.ts +28 -2
- package/src/omo-check.ts +2 -2
- package/src/transcript.ts +1 -1
- package/src/update/handoff.ts +1 -1
- package/src/usage.ts +1 -1
- package/src/version.ts +6 -6
- package/web/app.js +123 -25
- package/web/cardbind.d.ts +3 -0
- package/web/cardbind.js +15 -0
- package/web/devlog.js +2 -2
- package/web/gc.js +1 -1
- package/web/index.html +15 -3
- package/web/manual.d.ts +7 -0
- package/web/manual.js +29 -0
- package/web/style.css +9 -0
- package/web/tooltip.d.ts +22 -0
- package/web/tooltip.js +117 -0
package/src/cli/main.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { LEGACY_HOME } from "../runtime/paths.ts";
|
|
|
7
7
|
import { doctorCommand, systemDoctorDeps } from "./doctor.ts";
|
|
8
8
|
import { setupCommand, stdinPrompt, systemSetupDeps } from "./setup.ts";
|
|
9
9
|
import { pairCommand } from "./pair.ts";
|
|
10
|
+
import { setupHint } from "./setup-hint.ts";
|
|
10
11
|
import { helperCommand, updateCommand } from "../update/cli.ts";
|
|
11
12
|
import { handoffCommand } from "../update/handoff-system.ts";
|
|
12
13
|
import { HANDOFF_COMMAND } from "../update/strategy.ts";
|
|
@@ -14,10 +15,12 @@ import { EXIT_USAGE, parseMigrateArgs, parseMigrateRunArgs, runMigrate, runMigra
|
|
|
14
15
|
|
|
15
16
|
export const HELP = `zigmini - 개인 AI 에이전트 게이트웨이
|
|
16
17
|
|
|
18
|
+
처음 설치했으면 먼저 \`zigmini setup\`을 실행하세요. setup이 권한 동의, 엔진 설치, 설정, 시작을 합니다.
|
|
19
|
+
|
|
17
20
|
사용법: zigmini <명령> [옵션]
|
|
18
21
|
|
|
19
22
|
명령:
|
|
20
|
-
setup 설치합니다: 홈 폴더,
|
|
23
|
+
setup 설치합니다: 홈 폴더, 엔진, 설정, launchd 등록과 시작
|
|
21
24
|
start 게이트웨이와 브리지를 launchd에 등록하고 시작합니다
|
|
22
25
|
stop 게이트웨이와 브리지를 멈춥니다
|
|
23
26
|
stop --uninstall 멈추고 이 패키지의 launchd plist를 모두 지웁니다
|
|
@@ -104,6 +107,8 @@ if (import.meta.main) {
|
|
|
104
107
|
const ctx = systemContext();
|
|
105
108
|
const kind = installKind();
|
|
106
109
|
const notice = () => loadNotice();
|
|
110
|
+
const hint = setupHint(argv[0], ctx.paths.home, kind);
|
|
111
|
+
if (hint !== null) deps.err(`${hint}\n\n`);
|
|
107
112
|
if (argv[0] === "consent") {
|
|
108
113
|
if (argv.length !== 1) {
|
|
109
114
|
deps.err(`잘못된 인자: ${argv.join(" ")}\n\n${HELP}`);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { readConsent } from "../consent/consent.ts";
|
|
4
|
+
import type { InstallKind } from "../runtime/install-kind.ts";
|
|
5
|
+
import { HANDOFF_COMMAND } from "../update/strategy.ts";
|
|
6
|
+
import { UPDATER_COMMAND } from "./launchd.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Bun 1.4 blocks the postinstall of `bun add -g zigmini` and the package ships no install-time script, so the CLI tells
|
|
10
|
+
* the user to run `zigmini setup` until setup has written the consent record and config.env.
|
|
11
|
+
*/
|
|
12
|
+
export const SETUP_HINT = "아직 설치를 마치지 않았습니다. 먼저 `zigmini setup`을 실행하세요.";
|
|
13
|
+
export const UNHINTED_COMMANDS: readonly string[] = [
|
|
14
|
+
"setup", "consent", "migrate", "migrate-run", "--version", "-v", "--help", "-h", UPDATER_COMMAND, HANDOFF_COMMAND,
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export function setupDone(home: string): boolean {
|
|
18
|
+
return existsSync(join(home, "config.env")) && readConsent(home) !== null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function setupHint(command: string | undefined, home: string, kind: InstallKind): string | null {
|
|
22
|
+
if (kind === "checkout") return null;
|
|
23
|
+
if (command !== undefined && UNHINTED_COMMANDS.includes(command)) return null;
|
|
24
|
+
return setupDone(home) ? null : SETUP_HINT;
|
|
25
|
+
}
|
|
@@ -17,7 +17,7 @@ export const STORE = join("gateway", "gateway.sqlite");
|
|
|
17
17
|
export const GUARD_FILE = "schema-guard.json";
|
|
18
18
|
export const GLOBAL_ENGINE_PACKAGE = join("install", "global", "node_modules", "omo-ai", "package.json");
|
|
19
19
|
export const SCHEMA_TOO_NEW = "schema_too_new";
|
|
20
|
-
export const NOTICE_TITLE = "
|
|
20
|
+
export const NOTICE_TITLE = "엔진 스키마 충돌: 브리지를 멈췄습니다";
|
|
21
21
|
|
|
22
22
|
export type StoreReading =
|
|
23
23
|
| { readonly state: "ok"; readonly version: number }
|
package/src/hub.ts
CHANGED
|
@@ -47,6 +47,14 @@ export interface HubOptions {
|
|
|
47
47
|
media?: { dir: string; hostDir?: string };
|
|
48
48
|
now?: () => number;
|
|
49
49
|
rotation?: { maxAgeMs?: number; idleMs?: number; forceMaxBytes?: number; forceUpdateCompactions?: number };
|
|
50
|
+
/** Bug 152: bounded native ownership reconciliation; timers are injectable for deterministic tests. */
|
|
51
|
+
closeRecovery?: {
|
|
52
|
+
intervalMs?: number;
|
|
53
|
+
maxAttempts?: number;
|
|
54
|
+
timeoutMs?: number;
|
|
55
|
+
setTimeout?: (callback: () => void, ms: number) => ReturnType<typeof setTimeout>;
|
|
56
|
+
clearTimeout?: (timer: ReturnType<typeof setTimeout>) => void;
|
|
57
|
+
};
|
|
50
58
|
/** Answers `/version` as a notice; the command never reaches omo. */
|
|
51
59
|
versionReport?: () => Promise<string>;
|
|
52
60
|
/** Answers `/usage` (model account usage) as a notice; the command never reaches omo. */
|
|
@@ -89,6 +97,7 @@ export interface RotationStatus {
|
|
|
89
97
|
/** First sweep that found it held in this gateway run; null when not held or not yet swept. */
|
|
90
98
|
readonly blockedSince: string | null;
|
|
91
99
|
readonly blockers: readonly RotationBlocker[];
|
|
100
|
+
readonly closeRecovery?: { readonly state: "recovering" | "exhausted"; readonly attempts: number; readonly maxAttempts: number };
|
|
92
101
|
}
|
|
93
102
|
|
|
94
103
|
/** omo writes a new session file with its first entry; until then the session has no size. */
|
|
@@ -120,6 +129,23 @@ interface OpenResult {
|
|
|
120
129
|
streaming: boolean;
|
|
121
130
|
}
|
|
122
131
|
|
|
132
|
+
interface CloseRecovery {
|
|
133
|
+
attempts: number;
|
|
134
|
+
exhausted: boolean;
|
|
135
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
136
|
+
flight?: Promise<void>;
|
|
137
|
+
cancel?: () => void;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
interface SessionRetirement {
|
|
141
|
+
sessionId: string;
|
|
142
|
+
sessionPath: string;
|
|
143
|
+
from: string | null;
|
|
144
|
+
reason: RotationReason;
|
|
145
|
+
handoff: string;
|
|
146
|
+
closeAcknowledged?: boolean;
|
|
147
|
+
}
|
|
148
|
+
|
|
123
149
|
export interface SlashCommand {
|
|
124
150
|
name: string;
|
|
125
151
|
description: string;
|
|
@@ -230,7 +256,7 @@ export const MAIN_QUESTION_RELEASED =
|
|
|
230
256
|
const PERMISSION_PREFIX = "Permission required:";
|
|
231
257
|
const AUTO_CONTINUE_LIMIT = 2;
|
|
232
258
|
const CONTINUE_PROMPT =
|
|
233
|
-
"[게이트웨이 알림] 게이트웨이나
|
|
259
|
+
"[게이트웨이 알림] 게이트웨이나 엔진이 재시작되면서 이전 작업이 중간에 끊겼습니다. 세션 기록에서 마지막으로 한 일을 확인하고, 이미 끝난 단계는 반복하지 말고 끊긴 지점부터 이어서 마무리해 주세요.";
|
|
234
260
|
|
|
235
261
|
/** A model as omo reports it (get_available_models / get_state); only the fields the gateway uses. */
|
|
236
262
|
interface OmoModel {
|
|
@@ -274,6 +300,7 @@ export class Hub {
|
|
|
274
300
|
private readonly questions = new Map<string, QuestionCard>();
|
|
275
301
|
private readonly rotationHeld = new Map<string, { since: number; logged: boolean }>();
|
|
276
302
|
private readonly rotationFailures = new Map<string, { message: string; at: number }>();
|
|
303
|
+
private readonly closeRecoveries = new Map<string, CloseRecovery>();
|
|
277
304
|
private readonly legacyQuestionHistoryAliases = new Map<string, string>();
|
|
278
305
|
private readonly uiOwners = new Map<string, ScopedUiOwner>();
|
|
279
306
|
private readonly uiSessionScopes = new Map<string, string>();
|
|
@@ -668,6 +695,11 @@ export class Hub {
|
|
|
668
695
|
this.eventCount += 1;
|
|
669
696
|
if (status !== "ready") {
|
|
670
697
|
this.generation += 1;
|
|
698
|
+
for (const recovery of this.closeRecoveries.values()) {
|
|
699
|
+
if (recovery.timer !== undefined) this.clearCloseTimer(recovery.timer);
|
|
700
|
+
recovery.cancel?.();
|
|
701
|
+
}
|
|
702
|
+
this.closeRecoveries.clear();
|
|
671
703
|
this.sessionToConv.clear();
|
|
672
704
|
for (const owner of this.uiOwners.values()) this.decisions?.release(owner);
|
|
673
705
|
this.uiOwners.clear();
|
|
@@ -681,7 +713,7 @@ export class Hub {
|
|
|
681
713
|
rt.wake = null;
|
|
682
714
|
rt.notified.clear();
|
|
683
715
|
const scope = this.conversationScope(conv);
|
|
684
|
-
this.dialogs.cancelScope(scope, "
|
|
716
|
+
this.dialogs.cancelScope(scope, "엔진 연결이 끊겼습니다.");
|
|
685
717
|
for (const [id, card] of this.questions) {
|
|
686
718
|
if (card.scope !== scope) continue;
|
|
687
719
|
this.questions.delete(id);
|
|
@@ -709,17 +741,22 @@ export class Hub {
|
|
|
709
741
|
for (const conv of conversations) {
|
|
710
742
|
if (this.omoStatus !== "ready" || generation !== this.generation) return;
|
|
711
743
|
try {
|
|
744
|
+
if (this.closeUncertain(conv.id)) {
|
|
745
|
+
await this.reconcileClose(conv.id);
|
|
746
|
+
if (this.closeUncertain(conv.id) || generation !== this.generation) continue;
|
|
747
|
+
}
|
|
748
|
+
const current = this.store.getConversation(conv.id) ?? conv;
|
|
712
749
|
const pendingUsers = this.store.pendingMainUsers(conv.id);
|
|
713
750
|
if (pendingUsers.length > 0) {
|
|
714
|
-
await this.ensureSession(
|
|
751
|
+
await this.ensureSession(current);
|
|
715
752
|
for (const user of pendingUsers) {
|
|
716
753
|
const data = user.data as { text: string; images?: string[]; references?: MessageReference[] };
|
|
717
754
|
const input = referencedInput(data.text, data.images ?? [], data.references);
|
|
718
755
|
await this.prompt(conv.id, input.text, input.images, user.seq, true);
|
|
719
756
|
}
|
|
720
|
-
} else if (this.hasUnfinishedTurn(conv.id)) await this.recoverConversation(
|
|
721
|
-
else if (this.openQuestionCards(conv.id, this.conversationScope(conv.id)).size > 0) await this.ensureSession(
|
|
722
|
-
else if (this.store.unreportedWorkUpdates(conv.id).length > 0 || this.workSummary(conv.id).length > 0) await this.ensureSession(
|
|
757
|
+
} else if (this.hasUnfinishedTurn(conv.id)) await this.recoverConversation(current);
|
|
758
|
+
else if (this.openQuestionCards(conv.id, this.conversationScope(conv.id)).size > 0) await this.ensureSession(current);
|
|
759
|
+
else if (this.store.unreportedWorkUpdates(conv.id).length > 0 || this.workSummary(conv.id).length > 0) await this.ensureSession(current);
|
|
723
760
|
// Opening main comes first. A notification ACK or worker reconciliation cannot hold readiness.
|
|
724
761
|
void this.notifyWorkUpdates(conv.id);
|
|
725
762
|
} catch (error) {
|
|
@@ -763,7 +800,13 @@ export class Hub {
|
|
|
763
800
|
this.decisions?.refresh();
|
|
764
801
|
const rotationDone = this.rt(convId).rotationDone;
|
|
765
802
|
if (rotationDone) await rotationDone;
|
|
766
|
-
if (this.closeUncertain(convId))
|
|
803
|
+
if (this.closeUncertain(convId)) {
|
|
804
|
+
if (this.closeRecoveries.get(convId)?.exhausted) throw new Error("session_close_recovery_exhausted");
|
|
805
|
+
this.scheduleCloseRecovery(convId);
|
|
806
|
+
this.emit(convId, "notice", { kind: "session-close-recovery", level: "info",
|
|
807
|
+
message: "세션 닫기 상태를 확인하고 있습니다. 메시지는 확인 후 전달합니다." });
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
767
810
|
await this.prompt(convId, prompt.text, prompt.images, input.seq, true);
|
|
768
811
|
}
|
|
769
812
|
|
|
@@ -998,7 +1041,7 @@ export class Hub {
|
|
|
998
1041
|
&& (event.data as { input_seq?: number }).input_seq === input.seq).at(-1);
|
|
999
1042
|
if ((previous?.data as { kind?: string } | undefined)?.kind !== kind) this.emit(conv, "notice", {
|
|
1000
1043
|
kind, input_seq: input.seq, level: "warning",
|
|
1001
|
-
message: queued ? "메시지가
|
|
1044
|
+
message: queued ? "메시지가 엔진 대기열에 남아 있습니다. 자동 재전송하지 않습니다."
|
|
1002
1045
|
: "메시지의 실행 소비를 확인하지 못했습니다. 기록을 보존하며 자동 재전송하지 않습니다.",
|
|
1003
1046
|
});
|
|
1004
1047
|
if (queued && data.native_identity && rt.atomicQueuedResume && state &&
|
|
@@ -1127,50 +1170,33 @@ export class Hub {
|
|
|
1127
1170
|
) {
|
|
1128
1171
|
throw new Error("turn_running");
|
|
1129
1172
|
}
|
|
1130
|
-
|
|
1173
|
+
// Any rotation hands main's open waits of the closed session to the next one (inbox resume); bug 72.
|
|
1174
|
+
this.retireScopedQuestions(convId, sessionId);
|
|
1175
|
+
const retirement: SessionRetirement = {
|
|
1176
|
+
sessionId, sessionPath: source, from, reason,
|
|
1177
|
+
handoff: this.buildHandoff(convId, reason, source, handoffThrough),
|
|
1178
|
+
};
|
|
1179
|
+
let closeAcknowledged = false;
|
|
1131
1180
|
try {
|
|
1132
1181
|
await this.requireOmo().command("close_session", { sessionId });
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
}>("list_sessions", { include_all: true, include_workers: true });
|
|
1182
|
+
closeAcknowledged = true;
|
|
1183
|
+
const listed = await this.requireOmo().command("list_sessions", { include_all: true, include_workers: true });
|
|
1136
1184
|
if (
|
|
1137
|
-
generation !== this.generation || this.omoStatus !== "ready" ||
|
|
1138
|
-
|
|
1139
|
-
(entry) =>
|
|
1140
|
-
!entry ||
|
|
1141
|
-
typeof entry.sessionId !== "string" ||
|
|
1142
|
-
typeof entry.sessionPath !== "string" ||
|
|
1143
|
-
entry.sessionId === sessionId ||
|
|
1144
|
-
entry.sessionPath === source,
|
|
1145
|
-
)
|
|
1185
|
+
generation !== this.generation || this.omoStatus !== "ready" ||
|
|
1186
|
+
this.targetSessionPresent(listed, sessionId, source)
|
|
1146
1187
|
) {
|
|
1147
1188
|
throw new Error("session_retirement_unconfirmed");
|
|
1148
1189
|
}
|
|
1149
1190
|
} catch (error) {
|
|
1150
1191
|
this.emit(convId, "session.close.unknown", {
|
|
1151
|
-
|
|
1152
|
-
|
|
1192
|
+
...retirement,
|
|
1193
|
+
closeAcknowledged,
|
|
1153
1194
|
message: errorMessage(error),
|
|
1154
1195
|
});
|
|
1196
|
+
this.scheduleCloseRecovery(convId);
|
|
1155
1197
|
throw error;
|
|
1156
1198
|
}
|
|
1157
|
-
|
|
1158
|
-
this.sessionToConv.delete(sessionId);
|
|
1159
|
-
if (owner) this.releaseScopedUiOwner(owner);
|
|
1160
|
-
rt.sessionId = null;
|
|
1161
|
-
rt.itemId = null;
|
|
1162
|
-
rt.itemText = "";
|
|
1163
|
-
rt.notified.clear();
|
|
1164
|
-
const to = `${this.options.sessionDir.replace(/\/$/, "")}/zgw-${conv.id}-${fileStamp(this.now())}-${crypto.randomUUID().slice(0, 8)}.jsonl`;
|
|
1165
|
-
const handoff = this.buildHandoff(convId, reason, source, handoffThrough);
|
|
1166
|
-
this.store.rotateThread(convId, to, new Date(this.now()).toISOString(), handoff);
|
|
1167
|
-
this.emit(convId, "conversation.thread", { threadId: to });
|
|
1168
|
-
this.emit(convId, "session.rotated", { reason, from, to });
|
|
1169
|
-
this.emit(convId, "notice", {
|
|
1170
|
-
kind: "session-rotated",
|
|
1171
|
-
level: "info",
|
|
1172
|
-
message: `새 omo 세션으로 교체했습니다 (${this.reasonLabel(reason)}). 화면의 대화 기록은 그대로이고, 다음 메시지부터 새 세션에서 이어집니다.`,
|
|
1173
|
-
});
|
|
1199
|
+
this.finishRetirement(convId, retirement);
|
|
1174
1200
|
return this.store.getConversation(convId) ?? conv;
|
|
1175
1201
|
} finally {
|
|
1176
1202
|
rt.rotating = false;
|
|
@@ -1186,7 +1212,120 @@ export class Hub {
|
|
|
1186
1212
|
}
|
|
1187
1213
|
|
|
1188
1214
|
private closeUncertain(conv: string): boolean {
|
|
1189
|
-
return this.store.lastSeq(conv, "session.close.unknown") >
|
|
1215
|
+
return this.store.lastSeq(conv, "session.close.unknown") >
|
|
1216
|
+
Math.max(this.store.lastSeq(conv, "session.rotated"), this.store.lastSeq(conv, "session.close.resolved"));
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/** A valid native inventory proves absence by target identity, not by the shape of unrelated worker entries. */
|
|
1220
|
+
private targetSessionPresent(listed: unknown, sessionId: string, sessionPath: string): boolean {
|
|
1221
|
+
if (typeof listed !== "object" || listed === null || !("sessions" in listed) || !Array.isArray(listed.sessions)) {
|
|
1222
|
+
throw new Error("session_list_invalid");
|
|
1223
|
+
}
|
|
1224
|
+
return listed.sessions.some((entry: unknown) => typeof entry === "object" && entry !== null &&
|
|
1225
|
+
(("sessionId" in entry && entry.sessionId === sessionId) || ("sessionPath" in entry && entry.sessionPath === sessionPath)));
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
private finishRetirement(convId: string, retirement: SessionRetirement): void {
|
|
1229
|
+
const rt = this.rt(convId);
|
|
1230
|
+
const owner = this.ownerForConversation(convId, retirement.sessionId);
|
|
1231
|
+
this.sessionToConv.delete(retirement.sessionId);
|
|
1232
|
+
if (owner) this.releaseScopedUiOwner(owner);
|
|
1233
|
+
rt.sessionId = null;
|
|
1234
|
+
rt.itemId = null;
|
|
1235
|
+
rt.itemText = "";
|
|
1236
|
+
rt.notified.clear();
|
|
1237
|
+
const to = `${this.options.sessionDir.replace(/\/$/, "")}/zgw-${convId}-${fileStamp(this.now())}-${crypto.randomUUID().slice(0, 8)}.jsonl`;
|
|
1238
|
+
this.store.rotateThread(convId, to, new Date(this.now()).toISOString(), retirement.handoff);
|
|
1239
|
+
this.emit(convId, "conversation.thread", { threadId: to });
|
|
1240
|
+
this.emit(convId, "session.rotated", { reason: retirement.reason, from: retirement.from, to });
|
|
1241
|
+
this.emit(convId, "notice", { kind: "session-rotated", level: "info",
|
|
1242
|
+
message: `새 엔진 세션으로 교체했습니다 (${this.reasonLabel(retirement.reason)}). 화면의 대화 기록은 그대로이고, 다음 메시지부터 새 세션에서 이어집니다.` });
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
private closeTimer(callback: () => void, ms: number): ReturnType<typeof setTimeout> {
|
|
1246
|
+
const schedule = this.options.closeRecovery?.setTimeout;
|
|
1247
|
+
return schedule ? schedule(callback, ms) : setTimeout(callback, ms);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
private clearCloseTimer(timer: ReturnType<typeof setTimeout>): void {
|
|
1251
|
+
(this.options.closeRecovery?.clearTimeout ?? clearTimeout)(timer);
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
private scheduleCloseRecovery(convId: string): void {
|
|
1255
|
+
if (this.omoStatus !== "ready" || !this.closeUncertain(convId)) return;
|
|
1256
|
+
const recovery = this.closeRecoveries.get(convId) ?? { attempts: 0, exhausted: false };
|
|
1257
|
+
this.closeRecoveries.set(convId, recovery);
|
|
1258
|
+
if (recovery.exhausted || recovery.timer !== undefined || recovery.flight) return;
|
|
1259
|
+
recovery.timer = this.closeTimer(() => {
|
|
1260
|
+
recovery.timer = undefined;
|
|
1261
|
+
return this.reconcileClose(convId).then(() => {
|
|
1262
|
+
if (this.omoStatus === "ready" && !this.closeUncertain(convId)) return this.recover();
|
|
1263
|
+
});
|
|
1264
|
+
}, this.options.closeRecovery?.intervalMs ?? 5_000);
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
/** Never re-close an uncertain native or send to it before a fresh inventory establishes ownership. */
|
|
1268
|
+
private reconcileClose(convId: string): Promise<void> {
|
|
1269
|
+
const recovery = this.closeRecoveries.get(convId) ?? { attempts: 0, exhausted: false };
|
|
1270
|
+
this.closeRecoveries.set(convId, recovery);
|
|
1271
|
+
if (recovery.flight) return recovery.flight;
|
|
1272
|
+
if (recovery.exhausted || this.omoStatus !== "ready" || this.rt(convId).rotating) return Promise.resolve();
|
|
1273
|
+
if (recovery.timer !== undefined) this.clearCloseTimer(recovery.timer);
|
|
1274
|
+
recovery.timer = undefined;
|
|
1275
|
+
const generation = this.generation;
|
|
1276
|
+
const event = this.store.getEvent(this.store.lastSeq(convId, "session.close.unknown"));
|
|
1277
|
+
if (!event || !this.closeUncertain(convId)) return Promise.resolve();
|
|
1278
|
+
recovery.attempts += 1;
|
|
1279
|
+
recovery.flight = (async () => {
|
|
1280
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
1281
|
+
try {
|
|
1282
|
+
const cancelled = new Promise<never>((_resolve, reject) => {
|
|
1283
|
+
recovery.cancel = () => reject(new Error("session_close_recovery_disconnected"));
|
|
1284
|
+
timeout = this.closeTimer(() => reject(new Error("session_close_verification_timeout")),
|
|
1285
|
+
this.options.closeRecovery?.timeoutMs ?? 10_000);
|
|
1286
|
+
});
|
|
1287
|
+
const listed = await Promise.race([
|
|
1288
|
+
this.requireOmo().command("list_sessions", { include_all: true, include_workers: true }), cancelled,
|
|
1289
|
+
]);
|
|
1290
|
+
if (generation !== this.generation || this.omoStatus !== "ready") return;
|
|
1291
|
+
const data = event.data as Partial<SessionRetirement>;
|
|
1292
|
+
if (typeof data.sessionId !== "string" || typeof data.sessionPath !== "string") throw new Error("session_close_identity_missing");
|
|
1293
|
+
const present = this.targetSessionPresent(listed, data.sessionId, data.sessionPath);
|
|
1294
|
+
const current = this.store.getConversation(convId);
|
|
1295
|
+
if (current?.threadId === data.sessionPath && !present) {
|
|
1296
|
+
const reason = data.reason ?? "manual";
|
|
1297
|
+
this.finishRetirement(convId, { sessionId: data.sessionId, sessionPath: data.sessionPath, from: data.from ?? data.sessionPath,
|
|
1298
|
+
reason, handoff: data.handoff ?? this.buildHandoff(convId, reason, data.sessionPath, event.seq - 1) });
|
|
1299
|
+
} else {
|
|
1300
|
+
// A successful close releases this connection even when another attachment keeps the native alive.
|
|
1301
|
+
// Reopen that source before sending again; a rejected close must not duplicate a still-owned attachment.
|
|
1302
|
+
if (present && data.closeAcknowledged && current?.threadId === data.sessionPath) {
|
|
1303
|
+
this.sessionToConv.delete(data.sessionId);
|
|
1304
|
+
this.rt(convId).sessionId = null;
|
|
1305
|
+
} else if (present && this.rt(convId).sessionId === data.sessionId && current?.threadId === data.sessionPath) {
|
|
1306
|
+
// The rejected close kept its trusted open_session attachment; restore the UI authority retired above.
|
|
1307
|
+
this.bindScopedUiOwner({ conv: convId, scope: this.conversationScope(convId), sessionId: data.sessionId, sessionPath: data.sessionPath });
|
|
1308
|
+
}
|
|
1309
|
+
this.emit(convId, "session.close.resolved", { sessionId: data.sessionId, sessionPath: data.sessionPath,
|
|
1310
|
+
outcome: present ? "retained" : "superseded", attempts: recovery.attempts });
|
|
1311
|
+
this.emit(convId, "notice", { kind: "session-close-resolved", level: "info",
|
|
1312
|
+
message: "세션 닫기 상태를 확인했습니다. 기존 대화에서 메시지 처리를 계속합니다." });
|
|
1313
|
+
}
|
|
1314
|
+
this.closeRecoveries.delete(convId);
|
|
1315
|
+
} catch (error) {
|
|
1316
|
+
if (generation !== this.generation) return;
|
|
1317
|
+
if (recovery.attempts >= (this.options.closeRecovery?.maxAttempts ?? 3)) {
|
|
1318
|
+
recovery.exhausted = true;
|
|
1319
|
+
this.emit(convId, "error", { message: `session_close_recovery_exhausted: ${errorMessage(error)}. 메시지는 보관 중입니다. 엔진 연결과 세션 목록을 확인한 뒤 게이트웨이를 다시 시작하세요.` });
|
|
1320
|
+
}
|
|
1321
|
+
} finally {
|
|
1322
|
+
if (timeout !== undefined) this.clearCloseTimer(timeout);
|
|
1323
|
+
recovery.cancel = undefined;
|
|
1324
|
+
recovery.flight = undefined;
|
|
1325
|
+
if (generation === this.generation && this.closeUncertain(convId)) this.scheduleCloseRecovery(convId);
|
|
1326
|
+
}
|
|
1327
|
+
})();
|
|
1328
|
+
return recovery.flight;
|
|
1190
1329
|
}
|
|
1191
1330
|
|
|
1192
1331
|
private rotationBlocked(conv: string, inboxDecisions = true): boolean {
|
|
@@ -1228,9 +1367,9 @@ export class Hub {
|
|
|
1228
1367
|
return owned;
|
|
1229
1368
|
}
|
|
1230
1369
|
|
|
1231
|
-
/** What holds automatic rotation: the rotation
|
|
1370
|
+
/** What holds automatic rotation: the age rotation holds plus an unconfirmed session close. */
|
|
1232
1371
|
private rotationHolds(conv: string): RotationBlocker[] {
|
|
1233
|
-
const holds = this.
|
|
1372
|
+
const holds = this.rotationHoldsFor(conv, false);
|
|
1234
1373
|
return this.closeUncertain(conv) ? [{ kind: "session_close_unknown" }, ...holds] : holds;
|
|
1235
1374
|
}
|
|
1236
1375
|
|
|
@@ -1254,7 +1393,10 @@ export class Hub {
|
|
|
1254
1393
|
if (!conv.threadId?.startsWith("/")) continue;
|
|
1255
1394
|
const blockers = this.rotationHolds(conv.id);
|
|
1256
1395
|
const held = blockers.length > 0 ? this.rotationHeld.get(conv.id) : undefined;
|
|
1257
|
-
|
|
1396
|
+
const recovery = this.closeRecoveries.get(conv.id);
|
|
1397
|
+
status[conv.id] = { blocked: blockers.length > 0, blockedSince: held ? new Date(held.since).toISOString() : null, blockers,
|
|
1398
|
+
...(recovery ? { closeRecovery: { state: recovery.exhausted ? "exhausted" as const : "recovering" as const,
|
|
1399
|
+
attempts: recovery.attempts, maxAttempts: this.options.closeRecovery?.maxAttempts ?? 3 } } : {}) };
|
|
1258
1400
|
}
|
|
1259
1401
|
return status;
|
|
1260
1402
|
}
|
|
@@ -1295,9 +1437,18 @@ export class Hub {
|
|
|
1295
1437
|
}
|
|
1296
1438
|
}
|
|
1297
1439
|
|
|
1298
|
-
/** What holds a rotation now: every rotation blocker, or for a forced rotation only an unfinished main turn. */
|
|
1299
1440
|
private rotationHeldFor(conv: string, forced: boolean): boolean {
|
|
1300
|
-
return
|
|
1441
|
+
return this.rotationHoldsFor(conv, forced).length > 0;
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
/**
|
|
1445
|
+
* Bugs 72 and 86: what holds a rotation now. A main turn always holds; an age or manual rotation also waits for an open
|
|
1446
|
+
* approval dialog or native question. Works, their unreported reports and inbox decisions belong to the conversation,
|
|
1447
|
+
* not the session file, so they carry over to the next session (handoff, ZGW_WORK_STATE, work update wake) and never hold.
|
|
1448
|
+
*/
|
|
1449
|
+
private rotationHoldsFor(conv: string, forced: boolean): RotationBlocker[] {
|
|
1450
|
+
return this.rotationBlockers(conv, false).filter((blocker) =>
|
|
1451
|
+
blocker.kind === "main_turn" || (!forced && (blocker.kind === "dialog" || blocker.kind === "question")));
|
|
1301
1452
|
}
|
|
1302
1453
|
|
|
1303
1454
|
/** Bug 72 proposal 4: the user-confirmed limit this conversation's session reached, if any. */
|
|
@@ -1335,10 +1486,10 @@ export class Hub {
|
|
|
1335
1486
|
}
|
|
1336
1487
|
|
|
1337
1488
|
/** Bug 72 proposal 4: what stays open across the rotation, so the next session owns it. */
|
|
1338
|
-
private handoffOpenItems(conv: string
|
|
1489
|
+
private handoffOpenItems(conv: string): string[] {
|
|
1339
1490
|
const open = this.ownedOpenDecisions(conv);
|
|
1340
1491
|
return [
|
|
1341
|
-
|
|
1492
|
+
this.workSummary(conv).length > 0
|
|
1342
1493
|
? "끝나지 않은 작업은 그대로 이어집니다. 목록은 ZGW_WORK_STATE와 zgw_work_list로 보고, 보고하지 않은 결과는 작업 알림으로 다시 받습니다."
|
|
1343
1494
|
: "",
|
|
1344
1495
|
...(open.length > 0
|
|
@@ -1348,7 +1499,7 @@ export class Hub {
|
|
|
1348
1499
|
}
|
|
1349
1500
|
|
|
1350
1501
|
private rotationEligible(conv: ConversationInfo): boolean {
|
|
1351
|
-
if (!conv.threadId?.startsWith("/") || this.closeUncertain(conv.id) || this.
|
|
1502
|
+
if (!conv.threadId?.startsWith("/") || this.closeUncertain(conv.id) || this.rotationHeldFor(conv.id, false)) return false;
|
|
1352
1503
|
const started = Date.parse(this.store.getSessionMeta(conv.id).startedAt ?? conv.createdAt);
|
|
1353
1504
|
const used = this.store.lastSeq(conv.id, "message.user") > this.store.lastSeq(conv.id, "session.rotated");
|
|
1354
1505
|
if (!used) return false;
|
|
@@ -1386,9 +1537,9 @@ export class Hub {
|
|
|
1386
1537
|
lines.unshift(line);
|
|
1387
1538
|
}
|
|
1388
1539
|
return [
|
|
1389
|
-
`[게이트웨이 알림] 이 대화는 새
|
|
1540
|
+
`[게이트웨이 알림] 이 대화는 새 엔진 세션으로 교체됐습니다 (${this.reasonLabel(reason)}).`,
|
|
1390
1541
|
from ? `이전 세션의 전체 기록은 ${from} 에 남아 있습니다. 파일이 크니 필요할 때 필요한 부분만 검색해서 보세요.` : "",
|
|
1391
|
-
...this.handoffOpenItems(conv
|
|
1542
|
+
...this.handoffOpenItems(conv),
|
|
1392
1543
|
"아래는 직전 대화의 최근 부분입니다. 맥락으로만 참고하세요.",
|
|
1393
1544
|
"---",
|
|
1394
1545
|
...lines,
|
|
@@ -1456,7 +1607,7 @@ export class Hub {
|
|
|
1456
1607
|
if (!CONTEXT_OVERFLOW.test(message)) return { message };
|
|
1457
1608
|
return {
|
|
1458
1609
|
kind: "continuity-protected",
|
|
1459
|
-
message: `${message}. 같은
|
|
1610
|
+
message: `${message}. 같은 엔진 세션을 유지합니다. 원인을 해결한 뒤 계속할 지시를 보내 주세요. 이전 입력은 자동 재전송하지 않습니다.`,
|
|
1460
1611
|
};
|
|
1461
1612
|
}
|
|
1462
1613
|
|
package/src/main.ts
CHANGED
|
@@ -39,7 +39,7 @@ import { Hub, type ScopedQuestionFrame } from "./hub.ts";
|
|
|
39
39
|
import { parseSummaryModels, SpeechSummarizer } from "./speech-summary.ts";
|
|
40
40
|
import { MEDIA_MAX_BYTES, saveUpload, serveMedia } from "./media.ts";
|
|
41
41
|
import { OmoClient } from "./omo/client.ts";
|
|
42
|
-
import { bridgeLiveness, GenerationRouter,
|
|
42
|
+
import { bridgeLiveness, GenerationRouter, startupGenerations } from "./omo/generations.ts";
|
|
43
43
|
import { activeRecord, readRegistry, slotUrl } from "./runtime/slots.ts";
|
|
44
44
|
import { NATIVE_LIFECYCLE_PREFIX } from "./omo/lifecycle.ts";
|
|
45
45
|
import { RECOVERY_RECEIPT_PREFIX } from "./omo/recovery-receipt.ts";
|
|
@@ -66,6 +66,7 @@ import { acceptUpdateConsent, requestUpdate, type UpdateApiDeps } from "./update
|
|
|
66
66
|
import { PHASE_LABELS, readState, UPDATE_CONSENT_PATH, UPDATE_PATH, UPDATE_STATE_PATH, updateDir, UpdateWatcher } from "./update/state.ts";
|
|
67
67
|
import { resolveTarget, tarEntry } from "./update/target.ts";
|
|
68
68
|
import { registryUrl, UPDATE_CHECK_PATH, UPDATE_STATUS_PATH, UpdateChecker, versionAvailableEnvelope } from "./update/check.ts";
|
|
69
|
+
import { MANUAL_PATH, serveManual } from "./manual.ts";
|
|
69
70
|
import { clean as gcClean, GC_CLEAN_PATH, GC_PATH, type GcRoots, parseSelection as parseGcSelection, report as gcReport, systemGcDeps } from "./gc.ts";
|
|
70
71
|
|
|
71
72
|
// Exit 0 so launchd (KeepAlive SuccessfulExit=false) does not restart a gateway that has no consent.
|
|
@@ -189,14 +190,11 @@ function requesterId(slot: RuntimeSlot): string {
|
|
|
189
190
|
store.setSetting(key, id);
|
|
190
191
|
return id;
|
|
191
192
|
}
|
|
192
|
-
// Improvement 77: with run/runtime-slots.json the gateway connects to every listed generation; without it (or with
|
|
193
|
-
//
|
|
194
|
-
const
|
|
195
|
-
const endpoints: GenerationEndpoint[] = slotRegistry === null
|
|
196
|
-
? [{ slot: "a", url: config.omoUrl }]
|
|
197
|
-
: slotRegistry.slots.map((record) => ({ slot: record.slot, url: slotUrl(record) }));
|
|
193
|
+
// Improvement 77: with run/runtime-slots.json the gateway connects to every listed generation; without it (or with a
|
|
194
|
+
// ZGW_OMO_URL that names another OMO, as in Docker and the tests) it has one generation, as before (bug 134).
|
|
195
|
+
const startup = startupGenerations(process.env.ZGW_OMO_URL, `ws://127.0.0.1:${runtime.ports.bridge}`, () => readRegistry(runtime.slotsFile));
|
|
198
196
|
let connectionGeneration = 0;
|
|
199
|
-
const omo = new GenerationRouter(endpoints,
|
|
197
|
+
const omo = new GenerationRouter(startup.endpoints, startup.active, {
|
|
200
198
|
connect: (endpoint, handlers) => new OmoClient(endpoint.url, omoToken, handlers, requesterId(endpoint.slot)),
|
|
201
199
|
liveness: (endpoint) => bridgeLiveness(endpoint.url, omoToken),
|
|
202
200
|
log: (line) => console.log(line),
|
|
@@ -629,6 +627,7 @@ async function handle(req: Request, srv: Server<Conn>, trusted: boolean): Promis
|
|
|
629
627
|
const response = await handleProfileIcon(req, url, trusted, { icon: profileIcon, profile: () => readProfile(store), publish: (e) => publish(e) });
|
|
630
628
|
if (response) return response;
|
|
631
629
|
}
|
|
630
|
+
if (url.pathname === MANUAL_PATH) return serveManual(req, url);
|
|
632
631
|
// Plan Todo 23: the update button. The app port may ask too, but never for an update that needs a new consent.
|
|
633
632
|
if (url.pathname === UPDATE_PATH && req.method === "POST") {
|
|
634
633
|
const body = await req.json().catch(() => null) as { origin?: unknown } | null;
|
package/src/manual.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The user manual (docs/manual.md, docs/manual.ko.md) for the web 설정 "매뉴얼" button and the app's work status screen.
|
|
5
|
+
* Read only, on the web port and, behind the app device gate, on the app port. Korean is the default language.
|
|
6
|
+
*/
|
|
7
|
+
export const MANUAL_PATH = "/api/manual";
|
|
8
|
+
export const MANUAL_DIR = join(import.meta.dir, "..", "docs");
|
|
9
|
+
export type ManualLang = "ko" | "en";
|
|
10
|
+
|
|
11
|
+
/** The `lang` query value as a language: missing or empty is Korean; anything but `ko` or `en` is null. */
|
|
12
|
+
export function manualLang(value: string | null): ManualLang | null {
|
|
13
|
+
if (value === null || value === "" || value === "ko") return "ko";
|
|
14
|
+
return value === "en" ? "en" : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function manualFile(lang: ManualLang): string {
|
|
18
|
+
return lang === "en" ? "manual.md" : "manual.ko.md";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** `GET /api/manual?lang=ko|en`: the Markdown text; 400 for another language, 404 with a hint when the file is missing. */
|
|
22
|
+
export async function serveManual(req: Request, url: URL, dir: string = MANUAL_DIR): Promise<Response> {
|
|
23
|
+
if (req.method !== "GET") return Response.json({ error: "method_not_allowed" }, { status: 405 });
|
|
24
|
+
const lang = manualLang(url.searchParams.get("lang"));
|
|
25
|
+
if (lang === null) return Response.json({ error: "unknown_lang", message: "lang은 ko 또는 en입니다." }, { status: 400 });
|
|
26
|
+
const file = Bun.file(join(dir, manualFile(lang)));
|
|
27
|
+
if (!(await file.exists())) {
|
|
28
|
+
return Response.json({ error: "manual_missing", message: "이 설치에는 매뉴얼 파일이 없습니다. 게이트웨이를 업데이트하세요." }, { status: 404 });
|
|
29
|
+
}
|
|
30
|
+
return new Response(file, { headers: { "content-type": "text/markdown; charset=utf-8", "cache-control": "no-cache", "content-language": lang } });
|
|
31
|
+
}
|
package/src/omo/generations.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { RuntimeSlot } from "../runtime/paths.ts";
|
|
2
|
+
import { activeRecord, type SlotRegistry, slotUrl } from "../runtime/slots.ts";
|
|
2
3
|
import type { OmoClientHandlers, OmoEvent, OmoStatus } from "./client.ts";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -55,18 +56,43 @@ type Listed = { sessionId?: unknown; sessionPath?: unknown };
|
|
|
55
56
|
|
|
56
57
|
const LIVENESS_TIMEOUT_MS = 5000;
|
|
57
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Bug 134: an installed gateway gets ZGW_OMO_URL from config.env, and it names this Mac's own slot a bridge. That value
|
|
61
|
+
* must not hide run/runtime-slots.json: when the update handoff restarts the gateway, the generation that still holds the
|
|
62
|
+
* sessions can be slot b. The registry is used unless ZGW_OMO_URL names another OMO (Docker, tests).
|
|
63
|
+
*/
|
|
64
|
+
export function startupGenerations(explicitUrl: string | undefined, localUrl: string, registry: () => SlotRegistry | null): { endpoints: GenerationEndpoint[]; active: RuntimeSlot } {
|
|
65
|
+
const url = explicitUrl || localUrl;
|
|
66
|
+
const listed = isLocalBridge(url, localUrl) ? registry() : null;
|
|
67
|
+
if (listed === null) return { endpoints: [{ slot: "a", url }], active: "a" };
|
|
68
|
+
return { endpoints: listed.slots.map((record) => ({ slot: record.slot, url: slotUrl(record) })), active: activeRecord(listed).slot };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isLocalBridge(url: string, localUrl: string): boolean {
|
|
72
|
+
if (!URL.canParse(url)) return false;
|
|
73
|
+
const given = new URL(url);
|
|
74
|
+
const local = new URL(localUrl);
|
|
75
|
+
return given.protocol === local.protocol && given.port === local.port && given.pathname === "/" && given.search === "" &&
|
|
76
|
+
(given.hostname === "127.0.0.1" || given.hostname === "localhost");
|
|
77
|
+
}
|
|
78
|
+
|
|
58
79
|
/**
|
|
59
80
|
* Bug 118: after a bridge restart the previous OMO host can still hold its claim on a session file for a moment (the
|
|
60
81
|
* bridge's SIGTERM ends the host without waiting for it). The new host answers open_session with `session_path_in_use`
|
|
61
82
|
* and `errorData.retry_after_ms` until that claim is gone; OMO documents the refusal as retryable. The open waits it out
|
|
62
83
|
* for at most SESSION_PATH_WAIT_MS and never takes the path from the other host.
|
|
84
|
+
* Bug 134: `session_held` (a live holder outside this host's daemon family, e.g. a draining older generation) is waited
|
|
85
|
+
* out the same way; it carries no retry hint, so the open waits SESSION_PATH_RETRY_MS between tries.
|
|
63
86
|
*/
|
|
64
87
|
export const SESSION_PATH_WAIT_MS = 60_000;
|
|
65
88
|
export const SESSION_PATH_RETRY_MS = 2000;
|
|
66
89
|
|
|
67
|
-
/** Milliseconds to wait before reopening after this error, or null when the error is not a held session
|
|
90
|
+
/** Milliseconds to wait before reopening after this error, or null when the error is not a held session. */
|
|
68
91
|
export function sessionPathRetryMs(error: unknown): number | null {
|
|
69
|
-
if (typeof error !== "object" || error === null
|
|
92
|
+
if (typeof error !== "object" || error === null) return null;
|
|
93
|
+
const code = (error as { code?: unknown }).code;
|
|
94
|
+
if (code === "session_held") return SESSION_PATH_RETRY_MS;
|
|
95
|
+
if (code !== "session_path_in_use") return null;
|
|
70
96
|
const data = (error as { errorData?: unknown }).errorData;
|
|
71
97
|
const hint = typeof data === "object" && data !== null ? (data as { retry_after_ms?: unknown }).retry_after_ms : undefined;
|
|
72
98
|
return typeof hint === "number" && hint > 0 ? hint : SESSION_PATH_RETRY_MS;
|
package/src/omo-check.ts
CHANGED
|
@@ -45,8 +45,8 @@ export class OmoCheckScheduler {
|
|
|
45
45
|
const version = body.version;
|
|
46
46
|
const result = this.store.createOmoCheck(version, {
|
|
47
47
|
ownerConv: "main",
|
|
48
|
-
title:
|
|
49
|
-
goal:
|
|
48
|
+
title: `엔진 ${version} 검증`,
|
|
49
|
+
goal: `엔진 ${version} 후보 패치와 교체 가능 여부 검증 (운영 교체 금지)`,
|
|
50
50
|
cwd: this.options.cwd,
|
|
51
51
|
sessionPath: join(this.options.sessionDir, `zgw-omo-check-${version}.jsonl`),
|
|
52
52
|
requestKey: `omo-check:${version}`,
|
package/src/transcript.ts
CHANGED
|
@@ -62,7 +62,7 @@ export function toRecord(type: string, data: unknown, base: { ts: string; conv:
|
|
|
62
62
|
if (type === "message.user" && typeof d.text === "string") return { ...base, role: "user", text: d.text, src: d.src, trust: d.trust };
|
|
63
63
|
if (type === "message.final" && typeof d.text === "string") return { ...base, role: "assistant", text: d.text };
|
|
64
64
|
if (type === "session.rotated") {
|
|
65
|
-
return { ...base, role: "system", event: "session.rotated", text:
|
|
65
|
+
return { ...base, role: "system", event: "session.rotated", text: `엔진 세션 교체 (${String(d.reason)}): ${String(d.from)} -> ${String(d.to)}` };
|
|
66
66
|
}
|
|
67
67
|
return null;
|
|
68
68
|
}
|
package/src/update/handoff.ts
CHANGED
|
@@ -59,7 +59,7 @@ export const POLL_MS = 1000;
|
|
|
59
59
|
|
|
60
60
|
export type HandoffResult = "promoted" | "untouched_failure" | "failure";
|
|
61
61
|
|
|
62
|
-
const NOT_UP = `${BRIDGE_START_WAIT_MS / 1000}초 안에
|
|
62
|
+
const NOT_UP = `${BRIDGE_START_WAIT_MS / 1000}초 안에 엔진이 뜨지 않았습니다`;
|
|
63
63
|
|
|
64
64
|
function legacyRegistry(port: number, version: string, now: number): SlotRegistry {
|
|
65
65
|
return { schema: 1, slots: [{ slot: "a", state: "active", port, version, snapshot: null, engine: null, since: new Date(now).toISOString() }] };
|
package/src/usage.ts
CHANGED
|
@@ -279,7 +279,7 @@ function stamp(ms: number, timeZone: string | undefined): string {
|
|
|
279
279
|
return `${part("month")}/${part("day")} ${part("hour")}:${part("minute")}`;
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
-
const STATUS_TEXT = { expired: "토큰 만료 (
|
|
282
|
+
const STATUS_TEXT = { expired: "토큰 만료 (엔진이 다음 사용 때 갱신)", unsupported: "사용량 API 없음" } as const;
|
|
283
283
|
|
|
284
284
|
export function formatUsageReport(rows: readonly UsageRow[], timeZone?: string): string {
|
|
285
285
|
if (rows.length === 0) return "계정 사용량\n로그인한 모델 계정이 없습니다.";
|