zigmini 0.8.0-beta.4 → 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 +3 -3
- package/README.md +3 -3
- 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 +3 -3
- package/docs/manual.md +3 -3
- package/host/omo-bridge/continuity-control.ts +3 -2
- 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 +1 -1
- package/src/cli/doctor.ts +5 -5
- package/src/cli/main.ts +1 -1
- package/src/engine/schema-guard.ts +1 -1
- package/src/hub.ts +184 -43
- 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 +33 -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 +3 -3
- package/web/style.css +4 -0
- package/web/tooltip.d.ts +22 -0
- package/web/tooltip.js +117 -0
package/README.ko.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# zigmini
|
|
4
4
|
|
|
5
|
-
zigmini는 맥에서 도는 개인 AI 에이전트 게이트웨이입니다. 맥의 웹 화면과 Android 앱에서
|
|
5
|
+
zigmini는 맥에서 도는 개인 AI 에이전트 게이트웨이입니다. 맥의 웹 화면과 Android 앱에서 에이전트와 대화하고, 작업을 맡기고, 알림을 받습니다. 게이트웨이와 브리지는 macOS 로그인 때 launchd가 자동으로 띄웁니다. Docker는 필요하지 않습니다.
|
|
6
6
|
|
|
7
7
|
## 요구 사항
|
|
8
8
|
|
|
@@ -103,7 +103,7 @@ zigmini는 에이전트를 권한 프리셋 `full-access`로 실행합니다.
|
|
|
103
103
|
|
|
104
104
|
1. 권한 안내를 보여 주고 동의를 받습니다.
|
|
105
105
|
2. 홈 폴더 `~/.zigmini`를 만듭니다. 이 설치의 규칙 파일(`~/.zigmini/rules.md`)이 없으면 빈 파일을 만듭니다. 아래 "에이전트 규칙"을 보세요.
|
|
106
|
-
3. 에이전트 엔진(
|
|
106
|
+
3. 에이전트 엔진(omo-ai)의 고정 버전을 내려받아 해시를 확인하고 패치합니다. 버전은 패키지가 정합니다(`src/engine/compat.json`, 이 문서를 쓸 때 엔진 5.1.35). 엔진 라이선스 요약을 보여 주고 동의(`y`)를 받습니다.
|
|
107
107
|
4. 에이전트 폴더와 모델 계정 로그인 상태를 확인합니다. 기본 에이전트 폴더는 `~/.omo/agent`이고, 같은 폴더를 쓰는 다른 OMO와 로그인 정보를 같이 씁니다. 로그인하지 않았으면 로그인 명령을 출력합니다. 그 명령을 실행한 뒤 `/login`을 입력합니다.
|
|
108
108
|
5. Notion 토큰을 묻습니다(선택). 비우면 일정 기능이 꺼집니다.
|
|
109
109
|
6. 외부 접속 방식을 묻습니다. 아래 "외부 접속"을 보세요.
|
|
@@ -187,7 +187,7 @@ zigmini update <버전> # 지정한 버전으로 업데이트
|
|
|
187
187
|
|
|
188
188
|
새 버전이 무중단 전환을 지원하면(0.8.0-beta.1 다음 버전부터) 진행 중인 턴을 끊지 않습니다.
|
|
189
189
|
|
|
190
|
-
1. 새 브리지와
|
|
190
|
+
1. 새 브리지와 엔진(새 실행 세대)가 다른 포트에서 먼저 뜨고 확인을 받습니다. 확인에 실패하면 실행 중인 것은 그대로 두고 설치만 되돌립니다.
|
|
191
191
|
2. 게이트웨이만 다시 시작합니다. 웹과 폰은 수 초 뒤 다시 연결됩니다.
|
|
192
192
|
3. 새 세션은 새 세대에서 열립니다. 이전 세대의 세션은 각자 턴이 끝나면 새 세대로 옮겨집니다.
|
|
193
193
|
4. 이전 세대는 세션이 모두 옮겨지면 끝납니다. 메모리가 충분하면 최대 6시간, 아니면 최대 1시간을 기다립니다. 그때까지 끝나지 않은 턴은 끊깁니다. 마무리되면 main 채팅에 한 줄 알림이 옵니다.
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@ English | [한국어](README.ko.md)
|
|
|
2
2
|
|
|
3
3
|
# zigmini
|
|
4
4
|
|
|
5
|
-
zigmini: a personal AI agent gateway that runs on a Mac. You talk with the agent
|
|
5
|
+
zigmini: a personal AI agent gateway that runs on a Mac. You talk with the agent, give it work and get notifications from the web screen on the Mac and from an Android app. launchd starts the gateway and the bridge automatically when you log in to macOS. Docker is not necessary.
|
|
6
6
|
|
|
7
7
|
## Requirements
|
|
8
8
|
|
|
@@ -103,7 +103,7 @@ The gateway runs the agent with the permission preset `full-access`. The agent d
|
|
|
103
103
|
|
|
104
104
|
1. It shows the permission notice and asks for your consent.
|
|
105
105
|
2. It creates the home folder `~/.zigmini`. If the rules file of this installation (`~/.zigmini/rules.md`) does not exist, it creates an empty one. See "Agent rules" below.
|
|
106
|
-
3. It downloads the pinned version of the agent engine (
|
|
106
|
+
3. It downloads the pinned version of the agent engine (omo-ai), checks its hash and patches it. The package decides the version (`src/engine/compat.json`; engine 5.1.35 at the time of writing). It shows a summary of the engine license and asks for your consent (`y`).
|
|
107
107
|
4. It checks the agent folder and the login state of the model account. The default agent folder is `~/.omo/agent`. Other OMO installations that use the same folder share the login data. If you are not logged in, it prints a login command. Run that command, then type `/login`.
|
|
108
108
|
5. It asks for a Notion token (optional). If you leave it empty, the schedule feature is off.
|
|
109
109
|
6. It asks for the external access method. See "External access" below.
|
|
@@ -187,7 +187,7 @@ When a new app version is available, the app shows "새 앱 버전" (new app ver
|
|
|
187
187
|
|
|
188
188
|
If the new version supports a zero-downtime switch (versions after 0.8.0-beta.1), the update does not cut the turns in progress.
|
|
189
189
|
|
|
190
|
-
1. A new bridge and
|
|
190
|
+
1. A new bridge and engine (a new runtime generation) start first on other ports and must pass a check. If the check fails, the update keeps the running ones and rolls back only the installation.
|
|
191
191
|
2. Only the gateway restarts. The web and the phone connect again after a few seconds.
|
|
192
192
|
3. New sessions open in the new generation. Each session of the previous generation moves to the new generation when its turn ends.
|
|
193
193
|
4. The previous generation ends when all its sessions have moved. It waits up to 6 hours if there is enough memory, otherwise up to 1 hour. Turns that are not done by then are cut. When the switch is complete, a one-line notice appears in the main chat.
|
|
@@ -18,3 +18,4 @@ zigmini 패키지 공통 에이전트 규칙입니다. 게이트웨이 브리지
|
|
|
18
18
|
13. 사용자의 시간 표현은 사용자의 현지 시간으로 해석합니다. 보고하는 시각도 현지 시간으로 씁니다.
|
|
19
19
|
14. 사용자는 게이트웨이 프로필의 닉네임으로 부릅니다.
|
|
20
20
|
15. main은 오래 기다리거나 오래 실행하는 작업(배포, 전체 테스트, 뮤테이션 테스트, 다른 작업 기다리기)을 맡기면, 그 작업의 세션 기록이 30분 넘게 바뀌지 않을 때 main을 깨우는 감시를 직접 겁니다. 작업이 끝나면 감시를 끕니다. 작업의 "기다리는 중"·"진행 중" 보고만 믿지 않습니다. 사용자에게 그렇게 말하기 전에 그 작업의 마지막 기록 시각과 실행 중인 프로세스를 확인합니다. 작업이 멈췄으면 바로 그 작업에 조치를 보내고 사용자에게 알립니다. 작업은 긴 명령을 세션과 분리해 실행하고, 결과 파일을 직접 확인하며, 시간 상한을 겁니다.
|
|
21
|
+
16. 작업 하나에 나눌 수 있는 항목이 여럿이거나(예: 버그 여러 개, 구현·검증·문서 단계) 앞 단계를 기다려야 하는 단계가 있으면, 작업 세션은 mass-ulw 스킬을 씁니다. 작업 세션은 처음에 단계와 하위 작업을 정합니다. 서로 기다릴 필요가 없는 하위 작업은 최대한 병렬로 돌립니다. main은 이런 작업을 맡길 때 작업 지시문에 mass-ulw를 쓰라고 적습니다. 파일 하나 고침, 문서 한 줄 같은 작은 작업에는 mass-ulw를 쓰지 않습니다. 같은 파일을 고치는 하위 작업은 같은 단계에서 함께 돌리지 않습니다. 이런 하위 작업은 각자 worktree를 쓰거나 순서대로 돌립니다. 하위 작업을 병렬로 돌려도 무거운 명령(전체 테스트, 뮤테이션 테스트, 빌드 등)은 그 프로젝트나 설치 규칙이 정한 동시 실행 상한을 지킵니다. 규칙 8의 정리와 규칙 15의 감시는 하위 작업에도 적용합니다. 사용자가 질문만 하면 진행 중인 작업을 mass-ulw로 바꾸지 않습니다(규칙 4).
|
|
Binary file
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"versionCode":
|
|
3
|
-
"versionName": "0.8.0-beta.
|
|
4
|
-
"sha256": "
|
|
2
|
+
"versionCode": 24,
|
|
3
|
+
"versionName": "0.8.0-beta.5",
|
|
4
|
+
"sha256": "783c65a373c2b3f7b9f117e66237c6081a85a8f225c7ef9af182049e3a8a5393"
|
|
5
5
|
}
|
package/assets/build.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"commit":"
|
|
1
|
+
{"commit":"6eea099deac067c0e19d44b3bb2890a341438510"}
|
package/docs/manual.ko.md
CHANGED
|
@@ -19,7 +19,7 @@ zigmini는 화면 두 개를 제공합니다.
|
|
|
19
19
|
|
|
20
20
|
웹 화면은 세 부분으로 나뉩니다.
|
|
21
21
|
|
|
22
|
-
- 왼쪽 사이드바: 대화 목록, 새 대화 버튼(+), 작업 상태 영역, 내 프로필, 에이전트 프로필이 있습니다. 사이드바 맨 위의 "
|
|
22
|
+
- 왼쪽 사이드바: 대화 목록, 새 대화 버튼(+), 작업 상태 영역, 내 프로필, 에이전트 프로필이 있습니다. 사이드바 맨 위의 "엔진" 배지는 엔진 연결 상태를 보여 줍니다.
|
|
23
23
|
- 가운데 채팅: 헤더, 메시지 목록, 입력창이 있습니다.
|
|
24
24
|
- 오른쪽 패널: 확인함, 설정, 이벤트 패널이 열립니다.
|
|
25
25
|
|
|
@@ -208,7 +208,7 @@ Android 앱(APK)을 받는 QR이 보입니다. 폰 카메라로 QR을 찍어 APK
|
|
|
208
208
|
|
|
209
209
|
#### 정리
|
|
210
210
|
|
|
211
|
-
게이트웨이가 지워도 되는 후보를 보여 줍니다. 후보는 DB 백업,
|
|
211
|
+
게이트웨이가 지워도 되는 후보를 보여 줍니다. 후보는 DB 백업, 엔진 버전 폴더, 업데이트 남은 파일, 임시 폴더, 이전 RPC 데몬입니다. 지울 항목을 고르고 "선택한 항목 정리"를 누릅니다.
|
|
212
212
|
|
|
213
213
|
#### 음성 설정
|
|
214
214
|
|
|
@@ -265,7 +265,7 @@ Ctrl 단축키는 Cmd, Alt, Shift와 함께 누르면 동작하지 않습니다.
|
|
|
265
265
|
|
|
266
266
|
### 4.1 연결
|
|
267
267
|
|
|
268
|
-
앱 헤더 왼쪽에 지그 얼굴과 상태 태그가 있습니다. 상태 태그에는 "게이트웨이 재연결 중", "
|
|
268
|
+
앱 헤더 왼쪽에 지그 얼굴과 상태 태그가 있습니다. 상태 태그에는 "게이트웨이 재연결 중", "엔진 끊김", "엔진 연결됨", "작업 상태 확인 중" 중 하나가 보입니다.
|
|
269
269
|
|
|
270
270
|
| 조작 | 동작 |
|
|
271
271
|
| --- | --- |
|
package/docs/manual.md
CHANGED
|
@@ -19,7 +19,7 @@ zigmini has two surfaces.
|
|
|
19
19
|
|
|
20
20
|
The web screen has three parts.
|
|
21
21
|
|
|
22
|
-
- Left sidebar: the conversation list, the new conversation button (+), the work status area, your profile and the agent profile. The "
|
|
22
|
+
- Left sidebar: the conversation list, the new conversation button (+), the work status area, your profile and the agent profile. The "엔진" (engine) badge at the top shows the engine connection.
|
|
23
23
|
- Center chat: the header, the message list and the input box.
|
|
24
24
|
- Right panels: the inbox, settings and the event panel open here.
|
|
25
25
|
|
|
@@ -208,7 +208,7 @@ A QR for the Android app (APK) shows. Scan it with the phone camera, get the APK
|
|
|
208
208
|
|
|
209
209
|
#### Cleanup (정리)
|
|
210
210
|
|
|
211
|
-
The gateway lists things that are safe to delete: DB backups,
|
|
211
|
+
The gateway lists things that are safe to delete: DB backups, engine version folders, leftover update files, temp folders and old RPC daemons. Tick the ones to remove and click 선택한 항목 정리 (clean selected).
|
|
212
212
|
|
|
213
213
|
#### Voice settings
|
|
214
214
|
|
|
@@ -265,7 +265,7 @@ Ctrl shortcuts do nothing with Cmd, Alt or Shift held. They match by key positio
|
|
|
265
265
|
|
|
266
266
|
### 4.1 Connections
|
|
267
267
|
|
|
268
|
-
The app header shows the Zig face and a status tag on the left. The tag reads one of "게이트웨이 재연결 중" (reconnecting), "
|
|
268
|
+
The app header shows the Zig face and a status tag on the left. The tag reads one of "게이트웨이 재연결 중" (reconnecting), "엔진 끊김" (engine disconnected), "엔진 연결됨" (engine connected) or "작업 상태 확인 중" (checking work status).
|
|
269
269
|
|
|
270
270
|
| Action | What it does |
|
|
271
271
|
| --- | --- |
|
|
@@ -98,7 +98,7 @@ function durableReadiness(ctx: ContinuityContext): string | null {
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
// Bug 86: only a tool call without its result can still be running. Work that outlives its tool call (task children,
|
|
101
|
-
// detached eval cells, background terminals,
|
|
101
|
+
// detached eval cells, background terminals, open questions) is owned through wake_source_state, which
|
|
102
102
|
// readiness checks before this; a settled call of any tool name leaves nothing behind.
|
|
103
103
|
const entries = ctx.sessionManager.getEntries();
|
|
104
104
|
const results = new Set(
|
|
@@ -162,7 +162,8 @@ export default function continuityControl<Context extends ContinuityContext>(
|
|
|
162
162
|
if (runtime.hasPendingMessages()) return result("native_queue");
|
|
163
163
|
if (runtime.isCompacting?.() !== false) return result("native_compaction");
|
|
164
164
|
if (wakeEvidenceInvalid || !wakes.has("senpi-task")) return result("task_registry_uninitialized");
|
|
165
|
-
|
|
165
|
+
// Bug 153: session shutdown stops terminal monitors; monitoring now belongs to a separate OMO process.
|
|
166
|
+
if ([...wakes.entries()].some(([source, count]) => source !== "terminal-monitors" && count > 0)) return result("native_wake_ownership");
|
|
166
167
|
return result(durable ? durableReadiness(runtime) : null);
|
|
167
168
|
};
|
|
168
169
|
pi.rpc.handle("zgw.continuity_readiness", () => readiness(true));
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": 1,
|
|
3
|
+
"revision": 3,
|
|
3
4
|
"upstream": {
|
|
4
5
|
"omo": "5.1.35",
|
|
5
6
|
"senpi": "2026.10.16"
|
|
@@ -9,7 +10,9 @@
|
|
|
9
10
|
"entrypoint": "node_modules/@code-yeongyu/senpi/dist/cli.js",
|
|
10
11
|
"patchRoot": "node_modules",
|
|
11
12
|
"patches": [
|
|
12
|
-
"omo-5.1.35-gateway-port.patch"
|
|
13
|
+
"omo-5.1.35-gateway-port.patch",
|
|
14
|
+
"omo-5.1.35-image-replay.patch",
|
|
15
|
+
"omo-5.1.35-question-capability.patch"
|
|
13
16
|
],
|
|
14
17
|
"files": [
|
|
15
18
|
{
|
|
@@ -42,6 +45,11 @@
|
|
|
42
45
|
"originalSha256": "f98f1758e934135084d2765f73b20e4c22f6a74dce344569c6dfbfe9dc250cbc",
|
|
43
46
|
"patchedSha256": "218862c77a0df2691a521ae99c95e99acc64a8547dd6d6e627ebd98369d34fe0"
|
|
44
47
|
},
|
|
48
|
+
{
|
|
49
|
+
"path": "node_modules/@code-yeongyu/senpi/dist/core/extensions/builtin/anthropic-subscription/cold-seed-images.js",
|
|
50
|
+
"originalSha256": "f40ff494a945bc5188dca010476cac76ae60f178f67d5391457a04e7f266c16e",
|
|
51
|
+
"patchedSha256": "7c042bc1bfc0240cfd576cdc989f174dd248270219cc834788d22c9df3aa6903"
|
|
52
|
+
},
|
|
45
53
|
{
|
|
46
54
|
"path": "node_modules/@code-yeongyu/senpi/dist/core/extensions/builtin/ask-user/pending.js",
|
|
47
55
|
"originalSha256": "0f8156f0cdd1fbc4bb08486d0b8b6ff5f821b9ac5df4cf5056714689fb2bb0c5",
|
|
@@ -110,12 +118,12 @@
|
|
|
110
118
|
{
|
|
111
119
|
"path": "node_modules/@code-yeongyu/senpi/dist/modes/rpc/connection-handler.js",
|
|
112
120
|
"originalSha256": "20c6ef0ebe92cd4a94b78623bd6ae1144909709c10dd826e7c313932b6c28775",
|
|
113
|
-
"patchedSha256": "
|
|
121
|
+
"patchedSha256": "239dd0f928a7b2c89ca1ebde7bc1ca5f796f303a4430060796c8585099094da3"
|
|
114
122
|
},
|
|
115
123
|
{
|
|
116
124
|
"path": "node_modules/@code-yeongyu/senpi/dist/modes/rpc/connection-question-bridge.js",
|
|
117
125
|
"originalSha256": "0b63f63575df24ebf3f80dc891ffe876a9d908bc1a2264525919e4a8cef061b8",
|
|
118
|
-
"patchedSha256": "
|
|
126
|
+
"patchedSha256": "f88a26b34f127d35debbcc3e6b6a6d26a75b5a32aee852af9c51b6c770956813"
|
|
119
127
|
},
|
|
120
128
|
{
|
|
121
129
|
"path": "node_modules/@code-yeongyu/senpi/dist/modes/rpc/multi-session-host.js",
|
|
@@ -140,7 +148,7 @@
|
|
|
140
148
|
{
|
|
141
149
|
"path": "node_modules/@code-yeongyu/senpi/dist/modes/rpc/session-command-router.js",
|
|
142
150
|
"originalSha256": "3f952631024884c893a6e37549c2861e25b0bddc2387222006b77dcbaad78c9b",
|
|
143
|
-
"patchedSha256": "
|
|
151
|
+
"patchedSha256": "95515f802619da5e54352b8d996b8b71c6db91f1574470626239de2764367f9b"
|
|
144
152
|
},
|
|
145
153
|
{
|
|
146
154
|
"path": "node_modules/@earendil-works/pi-agent-core/dist/agent.d.ts",
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
diff -ruN a/@code-yeongyu/senpi/dist/core/extensions/builtin/anthropic-subscription/cold-seed-images.js b/@code-yeongyu/senpi/dist/core/extensions/builtin/anthropic-subscription/cold-seed-images.js
|
|
2
|
+
--- a/@code-yeongyu/senpi/dist/core/extensions/builtin/anthropic-subscription/cold-seed-images.js 2026-10-20 08:58:36
|
|
3
|
+
+++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/anthropic-subscription/cold-seed-images.js 2026-10-20 08:58:36
|
|
4
|
+
@@ -21,6 +21,7 @@
|
|
5
|
+
toolOutput: (toolName) => `[image returned by the ${toolName} tool to your own call (tool output, not a user attachment)]`,
|
|
6
|
+
userHistory: "[image the user attached in this earlier turn (history, not part of the current message)]",
|
|
7
|
+
duplicate: (origin, first) => `${subject(origin)}: identical to an image already shown above (${source(first)}); not attached again]`,
|
|
8
|
+
+ userAnswered: "[image the user attached in an earlier turn: you already saw and answered it, so it is not attached again and is not part of the current message; ask the user to attach it again if you need it]",
|
|
9
|
+
answered: (origin) => `[image returned earlier by the ${origin.toolName} tool, id=${origin.toolCallId}: already seen earlier in this conversation; run the tool again if you need it]`,
|
|
10
|
+
capped: (origin) => `${subject(origin)}: omitted from this replay, which re-sends at most ${MAX_REPLAYED_HISTORY_IMAGES} earlier images; re-read the source if you need it]`,
|
|
11
|
+
unreadable: (origin) => `${subject(origin)}: omitted because its image data is missing or unreadable]`,
|
|
12
|
+
@@ -86,14 +87,16 @@
|
|
13
|
+
return new Set(ordered.slice(0, MAX_REPLAYED_HISTORY_IMAGES).map(([hash]) => hash));
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
- * A tool image the assistant already responded to is not re-sent (#2768): a cold
|
|
17
|
+
- * seed packs the whole history into one user message, and replaying earlier tool
|
|
18
|
+
- * reads there made the model answer plain questions as if the user had just sent
|
|
19
|
+
- * those images. The user's own uploads and images still awaiting a response are
|
|
20
|
+
- * replayed.
|
|
21
|
+
+ * An image the assistant already responded to is not re-sent (#2768): a cold
|
|
22
|
+
+ * seed packs the whole history into one user message, and replaying earlier
|
|
23
|
+
+ * images there made the model answer plain questions as if the user had just
|
|
24
|
+
+ * sent those images. zigmini: this covers the user's own earlier uploads too;
|
|
25
|
+
+ * Claude Code saved each replayed upload as a new images/N.png on every model
|
|
26
|
+
+ * call and the model read it as a new attachment. Images still awaiting a
|
|
27
|
+
+ * response (the current turn) are replayed.
|
|
28
|
+
*/
|
|
29
|
+
function isAnsweredToolImage(occurrence) {
|
|
30
|
+
- return occurrence.answered && occurrence.origin.kind === "tool";
|
|
31
|
+
+ return occurrence.answered;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Rewrites the image entries of a cold-seed history (#2490): a tool-result image
|
|
35
|
+
@@ -124,8 +127,8 @@
|
|
36
|
+
if (decoded === undefined || hash === undefined) {
|
|
37
|
+
replacement = [{ type: "text", text: coldSeedImageText.unreadable(origin) }];
|
|
38
|
+
}
|
|
39
|
+
- else if (origin.kind === "tool" && occurrence.answered && shownEarlier === undefined) {
|
|
40
|
+
- replacement = [{ type: "text", text: coldSeedImageText.answered(origin) }];
|
|
41
|
+
+ else if (occurrence.answered && shownEarlier === undefined) {
|
|
42
|
+
+ replacement = [{ type: "text", text: origin.kind === "tool" ? coldSeedImageText.answered(origin) : coldSeedImageText.userAnswered }];
|
|
43
|
+
}
|
|
44
|
+
else if (!replayed.has(hash) && shownEarlier === undefined) {
|
|
45
|
+
replacement = [{ type: "text", text: coldSeedImageText.capped(origin) }];
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
diff --git a/@code-yeongyu/senpi/dist/modes/rpc/connection-handler.js b/@code-yeongyu/senpi/dist/modes/rpc/connection-handler.js
|
|
2
|
+
--- a/@code-yeongyu/senpi/dist/modes/rpc/connection-handler.js
|
|
3
|
+
+++ b/@code-yeongyu/senpi/dist/modes/rpc/connection-handler.js
|
|
4
|
+
@@ -313,7 +313,8 @@
|
|
5
|
+
* Create an extension UI context that uses the RPC protocol.
|
|
6
|
+
*/
|
|
7
|
+
const createExtensionUIContext = () => ({
|
|
8
|
+
- question: (request, opts) => clientCapabilities?.includes(QUESTION_CAPABILITY)
|
|
9
|
+
+ // zgw (bug 151): a binding made without a connection (a handoff reopen) asks the session's live connections.
|
|
10
|
+
+ question: (request, opts) => clientCapabilities?.includes(QUESTION_CAPABILITY) || options.clientInfo?.sessionHasCapability?.(QUESTION_CAPABILITY)
|
|
11
|
+
? questions.ask(request, opts)
|
|
12
|
+
: degradeQuestion(createExtensionUIContext(), request, opts),
|
|
13
|
+
select: (title, options, opts) => createDialogPromise(opts, undefined, {
|
|
14
|
+
diff --git a/@code-yeongyu/senpi/dist/modes/rpc/connection-question-bridge.js b/@code-yeongyu/senpi/dist/modes/rpc/connection-question-bridge.js
|
|
15
|
+
--- a/@code-yeongyu/senpi/dist/modes/rpc/connection-question-bridge.js
|
|
16
|
+
+++ b/@code-yeongyu/senpi/dist/modes/rpc/connection-question-bridge.js
|
|
17
|
+
@@ -148,7 +148,8 @@
|
|
18
|
+
else if (selected !== undefined)
|
|
19
|
+
answers[question.id] = { selected: [selected] };
|
|
20
|
+
}
|
|
21
|
+
- const comment = await ui.input("Anything else? (optional)", undefined, opts);
|
|
22
|
+
+ // zgw (bug 151): the optional comment never inherits the question's required flag.
|
|
23
|
+
+ const comment = await ui.input("Anything else? (optional)", undefined, opts && { ...opts, required: false });
|
|
24
|
+
const unanswered = request.questions.filter((q) => !answers[q.id]).map((q) => q.id);
|
|
25
|
+
return {
|
|
26
|
+
status: comment?.trim() ? "comment-submitted" : unanswered.length ? "cancelled" : "answered",
|
|
27
|
+
diff --git a/@code-yeongyu/senpi/dist/modes/rpc/session-command-router.js b/@code-yeongyu/senpi/dist/modes/rpc/session-command-router.js
|
|
28
|
+
--- a/@code-yeongyu/senpi/dist/modes/rpc/session-command-router.js
|
|
29
|
+
+++ b/@code-yeongyu/senpi/dist/modes/rpc/session-command-router.js
|
|
30
|
+
@@ -558,6 +558,27 @@
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
+ * zgw (bug 151): whether the session's clients advertised a capability, read when it is needed. A binding keeps the
|
|
35
|
+
+ * capabilities of the connection that opened it, and a handoff reopen has none. Attached live connections decide;
|
|
36
|
+
+ * with none attached, any live connection that advertised it does, since a question frame is replayed on attach.
|
|
37
|
+
+ */
|
|
38
|
+
+ sessionAdvertises(sessionId, capability) {
|
|
39
|
+
+ let attached = false;
|
|
40
|
+
+ for (const [connection, sessions] of this.sessionsByConnection) {
|
|
41
|
+
+ if (!sessions.has(sessionId) || this.releasedConnections.has(connection))
|
|
42
|
+
+ continue;
|
|
43
|
+
+ attached = true;
|
|
44
|
+
+ if (this.pendingCapabilities.get(connection)?.includes(capability))
|
|
45
|
+
+ return true;
|
|
46
|
+
+ }
|
|
47
|
+
+ if (attached)
|
|
48
|
+
+ return false;
|
|
49
|
+
+ for (const [connection, capabilities] of this.pendingCapabilities)
|
|
50
|
+
+ if (!this.releasedConnections.has(connection) && this.writer.hasRegisteredConnectionCapabilities(connection) && capabilities.includes(capability))
|
|
51
|
+
+ return true;
|
|
52
|
+
+ return false;
|
|
53
|
+
+ }
|
|
54
|
+
+ /**
|
|
55
|
+
* The successor's reopen of a handoff-parked session: the same open as an `open_session`, with no
|
|
56
|
+
* requesting connection and nothing to reply to (senpi#3368). Resolves `undefined` on success and
|
|
57
|
+
* the failure response otherwise, exactly as an open does.
|
|
58
|
+
@@ -675,6 +696,7 @@
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
connectionId: () => this.writer.currentConnection(),
|
|
62
|
+
+ sessionHasCapability: (capability) => this.sessionAdvertises(openedSession.sessionId, capability),
|
|
63
|
+
},
|
|
64
|
+
}));
|
|
65
|
+
if (entry.worker)
|
package/package.json
CHANGED
package/src/cli/doctor.ts
CHANGED
|
@@ -108,10 +108,10 @@ export async function collect(deps: DoctorDeps, ctx: LaunchdContext): Promise<Do
|
|
|
108
108
|
const health = await deps.health(healthUrl(ctx));
|
|
109
109
|
if (health === null) {
|
|
110
110
|
items.push({ id: "gateway.health", label: "게이트웨이", status: "bad", detail: `응답 없음 (${healthUrl(ctx)})` });
|
|
111
|
-
items.push({ id: "gateway.omo", label: "
|
|
111
|
+
items.push({ id: "gateway.omo", label: "엔진 연결", status: "bad", detail: "게이트웨이 응답 없음" });
|
|
112
112
|
} else {
|
|
113
113
|
items.push({ id: "gateway.health", label: "게이트웨이", status: health.status === 200 ? "ok" : "bad", detail: `HTTP ${health.status}` });
|
|
114
|
-
items.push({ id: "gateway.omo", label: "
|
|
114
|
+
items.push({ id: "gateway.omo", label: "엔진 연결", status: health.omo === "ready" ? "ok" : "bad", detail: health.omo === "ready" ? "" : `omo=${health.omo ?? "?"}` });
|
|
115
115
|
items.push(...await voiceItems(deps, ctx, health.voice));
|
|
116
116
|
if (deps.updateStatus !== undefined) items.push(updateItem(await deps.updateStatus(`http://127.0.0.1:${ctx.paths.ports.web}${UPDATE_STATUS_PATH}`)));
|
|
117
117
|
}
|
|
@@ -129,13 +129,13 @@ export async function collect(deps: DoctorDeps, ctx: LaunchdContext): Promise<Do
|
|
|
129
129
|
const pinned = deps.pinnedEngine();
|
|
130
130
|
const global = deps.globalEngine();
|
|
131
131
|
const latest = await deps.latestEngine();
|
|
132
|
-
items.push({ id: "omo.version", label: "
|
|
132
|
+
items.push({ id: "omo.version", label: "엔진 버전", status: pinned === null ? "warn" : "info",
|
|
133
133
|
detail: `고정 ${pinned ?? "없음"}, 전역 ${global ?? "?"}, npm 최신 ${latest ?? "확인 실패"}` });
|
|
134
134
|
const globalWarning = pinned === null ? null : globalEngineWarning(global, pinned);
|
|
135
|
-
if (globalWarning !== null) items.push({ id: "omo.global", label: "전역
|
|
135
|
+
if (globalWarning !== null) items.push({ id: "omo.global", label: "전역 OMO", status: "warn", detail: globalWarning });
|
|
136
136
|
|
|
137
137
|
const rpc = deps.rpcDaemons();
|
|
138
|
-
items.push({ id: "omo.rpc", label: "
|
|
138
|
+
items.push({ id: "omo.rpc", label: "엔진 RPC 호스트 데몬", status: rpc === null ? "warn" : "info", detail: rpc === null ? "확인 실패" : rpc.length === 0 ? "없음" : `${rpc.length}개 (나열만)` });
|
|
139
139
|
|
|
140
140
|
const schema = deps.schema();
|
|
141
141
|
items.push({ id: "schema", label: "thread gateway 스키마", status: schema.state === "too_new" ? "warn" : schema.state === "ok" ? "ok" : "info",
|
package/src/cli/main.ts
CHANGED
|
@@ -20,7 +20,7 @@ export const HELP = `zigmini - 개인 AI 에이전트 게이트웨이
|
|
|
20
20
|
사용법: zigmini <명령> [옵션]
|
|
21
21
|
|
|
22
22
|
명령:
|
|
23
|
-
setup 설치합니다: 홈 폴더,
|
|
23
|
+
setup 설치합니다: 홈 폴더, 엔진, 설정, launchd 등록과 시작
|
|
24
24
|
start 게이트웨이와 브리지를 launchd에 등록하고 시작합니다
|
|
25
25
|
stop 게이트웨이와 브리지를 멈춥니다
|
|
26
26
|
stop --uninstall 멈추고 이 패키지의 launchd plist를 모두 지웁니다
|
|
@@ -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 &&
|
|
@@ -1129,49 +1172,31 @@ export class Hub {
|
|
|
1129
1172
|
}
|
|
1130
1173
|
// Any rotation hands main's open waits of the closed session to the next one (inbox resume); bug 72.
|
|
1131
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;
|
|
1132
1180
|
try {
|
|
1133
1181
|
await this.requireOmo().command("close_session", { sessionId });
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
}>("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 });
|
|
1137
1184
|
if (
|
|
1138
|
-
generation !== this.generation || this.omoStatus !== "ready" ||
|
|
1139
|
-
|
|
1140
|
-
(entry) =>
|
|
1141
|
-
!entry ||
|
|
1142
|
-
typeof entry.sessionId !== "string" ||
|
|
1143
|
-
typeof entry.sessionPath !== "string" ||
|
|
1144
|
-
entry.sessionId === sessionId ||
|
|
1145
|
-
entry.sessionPath === source,
|
|
1146
|
-
)
|
|
1185
|
+
generation !== this.generation || this.omoStatus !== "ready" ||
|
|
1186
|
+
this.targetSessionPresent(listed, sessionId, source)
|
|
1147
1187
|
) {
|
|
1148
1188
|
throw new Error("session_retirement_unconfirmed");
|
|
1149
1189
|
}
|
|
1150
1190
|
} catch (error) {
|
|
1151
1191
|
this.emit(convId, "session.close.unknown", {
|
|
1152
|
-
|
|
1153
|
-
|
|
1192
|
+
...retirement,
|
|
1193
|
+
closeAcknowledged,
|
|
1154
1194
|
message: errorMessage(error),
|
|
1155
1195
|
});
|
|
1196
|
+
this.scheduleCloseRecovery(convId);
|
|
1156
1197
|
throw error;
|
|
1157
1198
|
}
|
|
1158
|
-
|
|
1159
|
-
this.sessionToConv.delete(sessionId);
|
|
1160
|
-
if (owner) this.releaseScopedUiOwner(owner);
|
|
1161
|
-
rt.sessionId = null;
|
|
1162
|
-
rt.itemId = null;
|
|
1163
|
-
rt.itemText = "";
|
|
1164
|
-
rt.notified.clear();
|
|
1165
|
-
const to = `${this.options.sessionDir.replace(/\/$/, "")}/zgw-${conv.id}-${fileStamp(this.now())}-${crypto.randomUUID().slice(0, 8)}.jsonl`;
|
|
1166
|
-
const handoff = this.buildHandoff(convId, reason, source, handoffThrough);
|
|
1167
|
-
this.store.rotateThread(convId, to, new Date(this.now()).toISOString(), handoff);
|
|
1168
|
-
this.emit(convId, "conversation.thread", { threadId: to });
|
|
1169
|
-
this.emit(convId, "session.rotated", { reason, from, to });
|
|
1170
|
-
this.emit(convId, "notice", {
|
|
1171
|
-
kind: "session-rotated",
|
|
1172
|
-
level: "info",
|
|
1173
|
-
message: `새 omo 세션으로 교체했습니다 (${this.reasonLabel(reason)}). 화면의 대화 기록은 그대로이고, 다음 메시지부터 새 세션에서 이어집니다.`,
|
|
1174
|
-
});
|
|
1199
|
+
this.finishRetirement(convId, retirement);
|
|
1175
1200
|
return this.store.getConversation(convId) ?? conv;
|
|
1176
1201
|
} finally {
|
|
1177
1202
|
rt.rotating = false;
|
|
@@ -1187,7 +1212,120 @@ export class Hub {
|
|
|
1187
1212
|
}
|
|
1188
1213
|
|
|
1189
1214
|
private closeUncertain(conv: string): boolean {
|
|
1190
|
-
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;
|
|
1191
1329
|
}
|
|
1192
1330
|
|
|
1193
1331
|
private rotationBlocked(conv: string, inboxDecisions = true): boolean {
|
|
@@ -1255,7 +1393,10 @@ export class Hub {
|
|
|
1255
1393
|
if (!conv.threadId?.startsWith("/")) continue;
|
|
1256
1394
|
const blockers = this.rotationHolds(conv.id);
|
|
1257
1395
|
const held = blockers.length > 0 ? this.rotationHeld.get(conv.id) : undefined;
|
|
1258
|
-
|
|
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 } } : {}) };
|
|
1259
1400
|
}
|
|
1260
1401
|
return status;
|
|
1261
1402
|
}
|
|
@@ -1396,7 +1537,7 @@ export class Hub {
|
|
|
1396
1537
|
lines.unshift(line);
|
|
1397
1538
|
}
|
|
1398
1539
|
return [
|
|
1399
|
-
`[게이트웨이 알림] 이 대화는 새
|
|
1540
|
+
`[게이트웨이 알림] 이 대화는 새 엔진 세션으로 교체됐습니다 (${this.reasonLabel(reason)}).`,
|
|
1400
1541
|
from ? `이전 세션의 전체 기록은 ${from} 에 남아 있습니다. 파일이 크니 필요할 때 필요한 부분만 검색해서 보세요.` : "",
|
|
1401
1542
|
...this.handoffOpenItems(conv),
|
|
1402
1543
|
"아래는 직전 대화의 최근 부분입니다. 맥락으로만 참고하세요.",
|
|
@@ -1466,7 +1607,7 @@ export class Hub {
|
|
|
1466
1607
|
if (!CONTEXT_OVERFLOW.test(message)) return { message };
|
|
1467
1608
|
return {
|
|
1468
1609
|
kind: "continuity-protected",
|
|
1469
|
-
message: `${message}. 같은
|
|
1610
|
+
message: `${message}. 같은 엔진 세션을 유지합니다. 원인을 해결한 뒤 계속할 지시를 보내 주세요. 이전 입력은 자동 재전송하지 않습니다.`,
|
|
1470
1611
|
};
|
|
1471
1612
|
}
|
|
1472
1613
|
|
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로그인한 모델 계정이 없습니다.";
|
package/src/version.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { CORE_NAME, ENGINE_PACKAGE, PLUGIN, OMO_REGISTRY_URL, readEnginePin, for
|
|
|
3
3
|
export { OMO_REGISTRY_URL } from "./engine/index.ts";
|
|
4
4
|
|
|
5
5
|
/** `/version`: the gateway answers it with a notice and never forwards it to omo. */
|
|
6
|
-
export const VERSION_COMMAND = { name: "version", description: "
|
|
6
|
+
export const VERSION_COMMAND = { name: "version", description: "게이트웨이·엔진 버전 보기 (게이트웨이가 바로 답함)", source: "extension" };
|
|
7
7
|
|
|
8
8
|
export function isVersionCommand(text: string): boolean {
|
|
9
9
|
return /^\/version$/i.test(text.trim());
|
|
@@ -104,22 +104,22 @@ export function formatVersionReport(facts: VersionFacts): string {
|
|
|
104
104
|
const build = facts.gateway.build ? `빌드 커밋 ${facts.gateway.build}` : "빌드 커밋 알 수 없음";
|
|
105
105
|
lines.push(`· 게이트웨이: ${facts.gateway.version ?? "알 수 없음"} (${build})`);
|
|
106
106
|
const pin = facts.pin?.omo
|
|
107
|
-
? ` · 저장소 고정
|
|
107
|
+
? ` · 저장소 고정 엔진 ${formatEnginePin(facts.pin)}`
|
|
108
108
|
: " · 저장소 고정 버전 읽기 실패";
|
|
109
109
|
let running: string | null = null;
|
|
110
|
-
if (!facts.omo) lines.push(`·
|
|
110
|
+
if (!facts.omo) lines.push(`· 엔진: 확인 실패 (엔진 연결 상태 ${facts.omoStatus})${pin}`);
|
|
111
111
|
else {
|
|
112
112
|
running = facts.omo.install;
|
|
113
113
|
const engine = facts.omo.engine ? ` (${CORE_NAME} ${facts.omo.engine})` : "";
|
|
114
|
-
lines.push(`·
|
|
114
|
+
lines.push(`· 엔진: ${facts.omo.install ?? "설치 경로 확인 실패"}${engine}${pin}`);
|
|
115
115
|
}
|
|
116
116
|
if ("version" in facts.latest) {
|
|
117
|
-
lines.push(`· 최신
|
|
117
|
+
lines.push(`· 최신 엔진: ${facts.latest.version} (npm ${ENGINE_PACKAGE})`);
|
|
118
118
|
const current = running ?? facts.pin?.omo ?? null;
|
|
119
119
|
if (current && isNewer(facts.latest.version, current)) {
|
|
120
120
|
lines.push(`업데이트 가능: ${current} → ${facts.latest.version} (자동으로 업데이트하지 않습니다)`);
|
|
121
121
|
}
|
|
122
|
-
} else lines.push(`· 최신
|
|
122
|
+
} else lines.push(`· 최신 엔진: 확인 실패 (${facts.latest.error})`);
|
|
123
123
|
return lines.join("\n");
|
|
124
124
|
}
|
|
125
125
|
|
package/web/app.js
CHANGED
|
@@ -26,7 +26,9 @@ import {
|
|
|
26
26
|
import { agentIconUrl, editAgentIcon, readAgentIcon, writeAgentIcon } from "./agent-icon.js";
|
|
27
27
|
import { hasTextSelection, panelShortcut } from "./shortcuts.js";
|
|
28
28
|
import { decisionCommand, lastSentLabel, lastSentTooltip, nextLastSent } from "./lastsent.js";
|
|
29
|
+
import { installTooltips } from "./tooltip.js";
|
|
29
30
|
import { placeRow, unseenEvents } from "./chatorder.js";
|
|
31
|
+
import { cardsOfDecision } from "./cardbind.js";
|
|
30
32
|
import { createRecall, recallKey, resetRecall } from "./recall.js";
|
|
31
33
|
import {
|
|
32
34
|
addReference, clearReferences, createReferences, inputData, markReferenceable, planSend, referenceSeqs, referenceSource, removeReference,
|
|
@@ -43,6 +45,8 @@ import { createAppDownload } from "./app-download.js";
|
|
|
43
45
|
import { cleanRequest, cleanResultLine, GC_CLEAN_PATH, GC_PATH, gcView } from "./gc.js";
|
|
44
46
|
import { manualFailureText, manualLinkLang, manualUrl, otherLang } from "./manual.js";
|
|
45
47
|
|
|
48
|
+
installTooltips(document, window);
|
|
49
|
+
|
|
46
50
|
const $ = (id) => document.getElementById(id);
|
|
47
51
|
const state = {
|
|
48
52
|
conversations: [],
|
|
@@ -676,12 +680,9 @@ function cardForDecision(id) {
|
|
|
676
680
|
return els.messages.querySelector(`.approval[data-decision-id="${CSS.escape(id)}"]`);
|
|
677
681
|
}
|
|
678
682
|
|
|
679
|
-
/**
|
|
680
|
-
function
|
|
681
|
-
|
|
682
|
-
if (explicit) return explicit;
|
|
683
|
-
const card = cardFor(originalActionId(decision));
|
|
684
|
-
return card && (card.dataset.decisionId === undefined || card.dataset.decisionId === decision.id) ? card : null;
|
|
683
|
+
/** Every mounted card of a decision; a rebound question keeps its decision id on a new card (bug 154). */
|
|
684
|
+
function cardsOf(decision) {
|
|
685
|
+
return cardsOfDecision(els.messages.querySelectorAll(".approval"), decision.id, originalActionId(decision));
|
|
685
686
|
}
|
|
686
687
|
|
|
687
688
|
/** Legacy or still-loading card bodies: controls on or off plus one status line, optionally with a retry control. */
|
|
@@ -715,13 +716,14 @@ function setCardBusy(actionId, busy) {
|
|
|
715
716
|
}
|
|
716
717
|
|
|
717
718
|
/** Re-renders the mounted card of a decision from canonical state; an id-less card follows only its own unique alias. */
|
|
718
|
-
function syncCard(decision) {
|
|
719
|
-
const card
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
719
|
+
function syncCard(decision, except = null) {
|
|
720
|
+
for (const card of cardsOf(decision)) {
|
|
721
|
+
if (card === except) continue;
|
|
722
|
+
const request = cardRequests.get(card);
|
|
723
|
+
if (!request) continue;
|
|
724
|
+
if (typeof request.decisionId !== "string" && decisionForAction(inbox.state, request.actionId)?.id !== decision.id) continue;
|
|
725
|
+
renderCardBody(card, decision);
|
|
726
|
+
}
|
|
725
727
|
}
|
|
726
728
|
|
|
727
729
|
/** One line of state under a canonical card: in transit, locked for review, offline, finished, or why it still waits. */
|
|
@@ -1556,7 +1558,7 @@ function renderOmo() {
|
|
|
1556
1558
|
if (omoStatus === null) return;
|
|
1557
1559
|
const ready = omoStatus === "ready";
|
|
1558
1560
|
const checking = ready && state.ws?.readyState === WebSocket.OPEN && isUnknown(activity, state.current);
|
|
1559
|
-
els.omo.textContent = checking ? "작업 상태 확인 중" : ready ? "
|
|
1561
|
+
els.omo.textContent = checking ? "작업 상태 확인 중" : ready ? "엔진 연결됨" : omoStatus === "connecting" ? "엔진 연결 중" : "엔진 끊김";
|
|
1560
1562
|
els.omo.classList.toggle("ok", ready);
|
|
1561
1563
|
}
|
|
1562
1564
|
|
|
@@ -1581,7 +1583,7 @@ function renderModels(info) {
|
|
|
1581
1583
|
modelSelect.append(group);
|
|
1582
1584
|
}
|
|
1583
1585
|
if (info.current) modelSelect.value = `${info.current.provider}/${info.current.id}`;
|
|
1584
|
-
modelSelect.title = info.current ?
|
|
1586
|
+
modelSelect.title = info.current ? `엔진 모델: ${info.current.name}` : "엔진 모델";
|
|
1585
1587
|
// A held switch stays on the current model until the next message compacts the context for the target.
|
|
1586
1588
|
const pending = info.pending ?? null;
|
|
1587
1589
|
modelPending.hidden = !pending;
|
|
@@ -2027,7 +2029,7 @@ function renderSlashMenu() {
|
|
|
2027
2029
|
});
|
|
2028
2030
|
return row;
|
|
2029
2031
|
});
|
|
2030
|
-
const note = slash.loading ? "
|
|
2032
|
+
const note = slash.loading ? "엔진 명령과 스킬을 불러오는 중…" : slash.failed ? "엔진 명령과 스킬을 불러오지 못했습니다. 웹 지그 명령만 보입니다." : rows.length === 0 ? "맞는 명령이나 스킬이 없습니다." : "";
|
|
2031
2033
|
if (note) {
|
|
2032
2034
|
const div = document.createElement("div");
|
|
2033
2035
|
div.className = "slash-note";
|
|
@@ -2110,7 +2112,7 @@ $("new-conv").onclick = () => {
|
|
|
2110
2112
|
};
|
|
2111
2113
|
$("interrupt").onclick = () => send({ type: "input.interrupt", conv: state.current, data: {} });
|
|
2112
2114
|
$("rotate-session").onclick = () => {
|
|
2113
|
-
if (!confirm("이 대화의
|
|
2115
|
+
if (!confirm("이 대화의 엔진 세션을 새로 시작할까요?\n화면의 기록은 그대로 남고, 최근 대화 일부를 새 세션에 넘겨 줍니다.")) return;
|
|
2114
2116
|
send({ type: "conversation.rotate", conv: state.current });
|
|
2115
2117
|
};
|
|
2116
2118
|
function toggleDevPanel() {
|
|
@@ -2583,6 +2585,12 @@ async function fetchDecisions(cursor) {
|
|
|
2583
2585
|
}
|
|
2584
2586
|
|
|
2585
2587
|
/** `refresh`: load the current copy even though one is cached (a mounted card the reset page no longer lists). */
|
|
2588
|
+
/** The mounted cards a detail load reports to: every card of the decision id, so a second card never waits on the first. */
|
|
2589
|
+
function detailCards(id, card) {
|
|
2590
|
+
const cards = [...els.messages.querySelectorAll(`.approval[data-decision-id="${CSS.escape(id)}"]`)];
|
|
2591
|
+
return card?.isConnected && !cards.includes(card) ? [...cards, card] : cards;
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2586
2594
|
async function loadDecisionDetail(id, card, { refresh = false } = {}) {
|
|
2587
2595
|
const generation = decisionGeneration;
|
|
2588
2596
|
if ((!refresh && decisionById(inbox.state, id)) || !inbox.online || !beginDetailLoad(inbox.state, id, generation)) return;
|
|
@@ -2592,19 +2600,19 @@ async function loadDecisionDetail(id, card, { refresh = false } = {}) {
|
|
|
2592
2600
|
const body = await res.json().catch(() => null);
|
|
2593
2601
|
if (!res.ok) {
|
|
2594
2602
|
if (!failDetailLoad(inbox.state, id, generation)) return;
|
|
2595
|
-
|
|
2603
|
+
for (const shown of detailCards(id, card)) setCardState(shown, { disabled: true, note: `결정 상태를 불러오지 못했습니다 (${res.status}).`, retry });
|
|
2596
2604
|
return;
|
|
2597
2605
|
}
|
|
2598
2606
|
const result = finishDetailLoad(inbox.state, id, generation, body);
|
|
2599
2607
|
if (!result.accepted) {
|
|
2600
|
-
if (
|
|
2608
|
+
if (result.reason === "mismatch") for (const shown of detailCards(id, card)) setCardState(shown, { disabled: true, note: "결정 식별자가 일치하지 않습니다.", retry });
|
|
2601
2609
|
return;
|
|
2602
2610
|
}
|
|
2603
2611
|
const decision = decisionById(inbox.state, id);
|
|
2604
2612
|
if (decision) syncCard(decision);
|
|
2605
2613
|
} catch (err) {
|
|
2606
2614
|
if (!failDetailLoad(inbox.state, id, generation)) return;
|
|
2607
|
-
|
|
2615
|
+
for (const shown of detailCards(id, card)) setCardState(shown, {
|
|
2608
2616
|
disabled: true,
|
|
2609
2617
|
note: `결정 상태를 불러오지 못했습니다: ${err instanceof Error ? err.message : String(err)}`,
|
|
2610
2618
|
retry,
|
|
@@ -2938,8 +2946,7 @@ function reviewLatest(decision) {
|
|
|
2938
2946
|
|
|
2939
2947
|
/** Both surfaces render the same canonical decision; the surface being edited keeps its focus. */
|
|
2940
2948
|
function syncDecisionSurfaces(decision, except = null) {
|
|
2941
|
-
|
|
2942
|
-
if (card && card !== except) syncCard(decision);
|
|
2949
|
+
syncCard(decision, except);
|
|
2943
2950
|
if (inbox.state.items.has(decision.id) && inbox.nodes.get(decision.id) !== except) renderInboxItem(decision);
|
|
2944
2951
|
}
|
|
2945
2952
|
|
|
@@ -3175,8 +3182,7 @@ function settleSubmit(decision, result) {
|
|
|
3175
3182
|
break;
|
|
3176
3183
|
case "gone": {
|
|
3177
3184
|
forgetDecision(inbox.state, id);
|
|
3178
|
-
const card
|
|
3179
|
-
if (card) {
|
|
3185
|
+
for (const card of els.messages.querySelectorAll(`.approval[data-decision-id="${CSS.escape(id)}"]`)) {
|
|
3180
3186
|
card.removeAttribute("aria-busy");
|
|
3181
3187
|
setCardState(card, { disabled: true, note: "서버에 더 이상 없는 요청입니다." });
|
|
3182
3188
|
}
|
|
@@ -3596,10 +3602,12 @@ function gcRow(row) {
|
|
|
3596
3602
|
const name = document.createElement("span");
|
|
3597
3603
|
name.className = "gc-name";
|
|
3598
3604
|
name.textContent = row.label;
|
|
3599
|
-
name.
|
|
3605
|
+
name.dataset.tip = row.title;
|
|
3606
|
+
name.dataset.tipOverflow = "";
|
|
3600
3607
|
const sub = document.createElement("span");
|
|
3601
3608
|
sub.className = "gc-sub";
|
|
3602
3609
|
sub.textContent = row.sub;
|
|
3610
|
+
sub.dataset.tipOverflow = "";
|
|
3603
3611
|
main.append(name, sub);
|
|
3604
3612
|
label.append(box, main);
|
|
3605
3613
|
li.append(label);
|
package/web/cardbind.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Which chat cards show one decision (bug 154). A question that is rebound (for example after a session change) gets a
|
|
2
|
+
// new card that keeps the same decision id, so one decision can own several cards. Every one of them must follow the
|
|
3
|
+
// canonical decision; updating only the first left the others open or stuck at "확인 중…" after the answer was delivered.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Every mounted card of a decision: the cards that carry its explicit id, else the id-less cards of its original action
|
|
7
|
+
* alias. A card bound to another decision never matches.
|
|
8
|
+
*/
|
|
9
|
+
export function cardsOfDecision(cards, decisionId, aliasActionId) {
|
|
10
|
+
const list = [...cards];
|
|
11
|
+
const explicit = list.filter((card) => card.dataset.decisionId === decisionId);
|
|
12
|
+
if (explicit.length > 0) return explicit;
|
|
13
|
+
return list.filter((card) => card.dataset.actionId === aliasActionId &&
|
|
14
|
+
(card.dataset.decisionId === undefined || card.dataset.decisionId === decisionId));
|
|
15
|
+
}
|
package/web/devlog.js
CHANGED
|
@@ -71,9 +71,9 @@ function summary(type, d = {}) {
|
|
|
71
71
|
case "notice":
|
|
72
72
|
return clip(d.message);
|
|
73
73
|
case "omo.status":
|
|
74
|
-
return clip(
|
|
74
|
+
return clip(`엔진 ${d.status}${d.detail ? ` · ${d.detail}` : ""}`);
|
|
75
75
|
case "state.snapshot":
|
|
76
|
-
return `대화 ${d.conversations?.length ?? 0} · 승인 대기 ${d.approvals?.length ?? 0} ·
|
|
76
|
+
return `대화 ${d.conversations?.length ?? 0} · 승인 대기 ${d.approvals?.length ?? 0} · 엔진 ${d.omo}`;
|
|
77
77
|
case "history":
|
|
78
78
|
return `${d.conv} 기록 ${d.events?.length ?? 0}건`;
|
|
79
79
|
case "conversation.created":
|
package/web/gc.js
CHANGED
|
@@ -5,7 +5,7 @@ export const GC_PATH = "/api/gc";
|
|
|
5
5
|
export const GC_CLEAN_PATH = "/api/gc/clean";
|
|
6
6
|
export const KIND_LABELS = Object.freeze({
|
|
7
7
|
"db-backup": "DB 백업",
|
|
8
|
-
"omo-version": "
|
|
8
|
+
"omo-version": "엔진 버전 폴더",
|
|
9
9
|
"update-file": "업데이트 남은 파일",
|
|
10
10
|
"temp-dir": "임시 폴더",
|
|
11
11
|
"rpc-daemon": "이전 RPC 데몬",
|
package/web/index.html
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
<aside class="sidebar">
|
|
12
12
|
<header class="sidebar-head">
|
|
13
13
|
<span class="sidebar-title">zigmini</span>
|
|
14
|
-
<span class="badge" id="omo-status" title="
|
|
14
|
+
<span class="badge" id="omo-status" title="엔진 연결 상태">엔진</span>
|
|
15
15
|
</header>
|
|
16
16
|
<div class="sidebar-section">
|
|
17
17
|
<div class="section-label">
|
|
@@ -60,9 +60,9 @@
|
|
|
60
60
|
<span class="login-notice" id="schema-notice" role="alert" hidden></span>
|
|
61
61
|
<button type="button" class="update-notice" id="update-notice" title="설정의 업데이트 열기" hidden></button>
|
|
62
62
|
<div class="chat-actions">
|
|
63
|
-
<select class="model-select" id="model-select" title="
|
|
63
|
+
<select class="model-select" id="model-select" title="엔진이 쓸 모델" disabled><option>모델 불러오는 중</option></select>
|
|
64
64
|
<span class="model-pending" id="model-pending" hidden></span>
|
|
65
|
-
<button class="text-btn" id="rotate-session" title="이 대화의
|
|
65
|
+
<button class="text-btn" id="rotate-session" title="이 대화의 엔진 세션을 새로 시작합니다. 화면의 기록은 그대로 남습니다.">새 세션</button>
|
|
66
66
|
<button class="text-btn" id="toggle-voice" title="이 게이트웨이의 음성 서버(음성 인식·음성 합성)를 켜고 끄기. 끄면 마이크·호출어·박수 감지·음성 답변을 쓸 수 없습니다." aria-pressed="true">음성 켜짐</button>
|
|
67
67
|
<button class="text-btn" id="voice-install" title="음성 서버(uv, Python 환경, 음성 모델)를 이 맥의 zigmini 폴더에 내려받아 설치합니다" hidden>음성 설치</button>
|
|
68
68
|
<button class="text-btn" id="toggle-clap" title="박수를 두 번 치면 호출어 대기를 켜고 끄기" aria-pressed="false">박수 감지 꺼짐</button>
|
package/web/style.css
CHANGED
|
@@ -561,3 +561,7 @@ button { font: inherit; color: inherit; cursor: pointer; }
|
|
|
561
561
|
.app.status-open .ws-title { font-size: 14px; color: var(--text); }
|
|
562
562
|
.app.status-open .ws-caption { font-size: 13px; padding: 4px 10px; }
|
|
563
563
|
}
|
|
564
|
+
|
|
565
|
+
/* Bug 90: the page-drawn tooltip (web/tooltip.js). */
|
|
566
|
+
.zgw-tip { position: fixed; z-index: 10000; max-width: min(360px, calc(100vw - 16px)); padding: 6px 9px; border-radius: 6px; background: #111214; color: var(--text); border: 1px solid #3f4147; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.45); font-size: 12px; line-height: 1.45; white-space: pre-line; overflow-wrap: anywhere; pointer-events: none; }
|
|
567
|
+
.zgw-tip[hidden] { display: none; }
|
package/web/tooltip.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type Box = { left: number; top: number; right: number; bottom: number };
|
|
2
|
+
export type TipElement = {
|
|
3
|
+
getAttribute(name: string): string | null;
|
|
4
|
+
setAttribute(name: string, value: string): void;
|
|
5
|
+
removeAttribute(name: string): void;
|
|
6
|
+
hasAttribute(name: string): boolean;
|
|
7
|
+
textContent: string | null;
|
|
8
|
+
scrollWidth: number;
|
|
9
|
+
clientWidth: number;
|
|
10
|
+
scrollHeight: number;
|
|
11
|
+
clientHeight: number;
|
|
12
|
+
};
|
|
13
|
+
export const TIP_GAP: number;
|
|
14
|
+
export const TIP_MARGIN: number;
|
|
15
|
+
export const TIP_ID: string;
|
|
16
|
+
export function placeTip(anchor: Box, tip: { width: number; height: number }, view: { width: number; height: number }): { left: number; top: number; side: "above" | "below" };
|
|
17
|
+
export function isTruncated(el: { scrollWidth: number; clientWidth: number; scrollHeight: number; clientHeight: number }): boolean;
|
|
18
|
+
export function adoptTitle(el: Pick<TipElement, "getAttribute" | "setAttribute" | "removeAttribute" | "hasAttribute">): void;
|
|
19
|
+
export function tipText(el: TipElement): string;
|
|
20
|
+
export function addId(list: string | null, id: string): string;
|
|
21
|
+
export function removeId(list: string | null, id: string): string;
|
|
22
|
+
export function installTooltips(doc: Document, win: Window & typeof globalThis): void;
|
package/web/tooltip.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Bug 90: the page draws its own tooltip. Some browsers (the user's, likely Aside) never show the native `title`
|
|
2
|
+
// tooltip, so every `title` in the page moves to `data-tip` and one shared element shows it on hover and keyboard focus,
|
|
3
|
+
// linked with aria-describedby. Improvement 143: `data-tip-overflow` shows the tip only while the text is cut off.
|
|
4
|
+
|
|
5
|
+
export const TIP_GAP = 6;
|
|
6
|
+
export const TIP_MARGIN = 8;
|
|
7
|
+
export const TIP_ID = "zgw-tip";
|
|
8
|
+
const HOVER_DELAY_MS = 250;
|
|
9
|
+
const SELECTOR = "[data-tip], [data-tip-overflow]";
|
|
10
|
+
|
|
11
|
+
export function placeTip(anchor, tip, view) {
|
|
12
|
+
const left = Math.max(TIP_MARGIN, Math.min((anchor.left + anchor.right) / 2 - tip.width / 2, view.width - tip.width - TIP_MARGIN));
|
|
13
|
+
const below = anchor.bottom + TIP_GAP;
|
|
14
|
+
const above = anchor.top - TIP_GAP - tip.height;
|
|
15
|
+
const fitsBelow = below + tip.height <= view.height - TIP_MARGIN;
|
|
16
|
+
if (!fitsBelow && above >= TIP_MARGIN) return { left, top: above, side: "above" };
|
|
17
|
+
const top = Math.max(TIP_MARGIN, Math.min(below, view.height - tip.height - TIP_MARGIN));
|
|
18
|
+
return { left, top, side: "below" };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isTruncated(el) {
|
|
22
|
+
return el.scrollWidth > el.clientWidth || el.scrollHeight > el.clientHeight;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function adoptTitle(el) {
|
|
26
|
+
if (!el.hasAttribute("title")) return;
|
|
27
|
+
const text = el.getAttribute("title");
|
|
28
|
+
el.removeAttribute("title");
|
|
29
|
+
if (text) el.setAttribute("data-tip", text);
|
|
30
|
+
else el.removeAttribute("data-tip");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function tipText(el) {
|
|
34
|
+
if (el.hasAttribute("data-tip-overflow") && !isTruncated(el)) return "";
|
|
35
|
+
return el.getAttribute("data-tip") || (el.hasAttribute("data-tip-overflow") ? el.textContent : "") || "";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function addId(list, id) {
|
|
39
|
+
const ids = (list ?? "").split(/\s+/).filter(Boolean);
|
|
40
|
+
return ids.includes(id) ? ids.join(" ") : [...ids, id].join(" ");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function removeId(list, id) {
|
|
44
|
+
return (list ?? "").split(/\s+/).filter((part) => part && part !== id).join(" ");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function installTooltips(doc, win) {
|
|
48
|
+
const tip = doc.createElement("div");
|
|
49
|
+
tip.id = TIP_ID;
|
|
50
|
+
tip.className = "zgw-tip";
|
|
51
|
+
tip.setAttribute("role", "tooltip");
|
|
52
|
+
tip.hidden = true;
|
|
53
|
+
doc.body.append(tip);
|
|
54
|
+
let owner = null;
|
|
55
|
+
let timer = 0;
|
|
56
|
+
|
|
57
|
+
const adoptAll = (root) => {
|
|
58
|
+
if (root.nodeType !== 1) return;
|
|
59
|
+
adoptTitle(root);
|
|
60
|
+
for (const el of root.querySelectorAll("[title]")) adoptTitle(el);
|
|
61
|
+
};
|
|
62
|
+
adoptAll(doc.body);
|
|
63
|
+
new win.MutationObserver((records) => {
|
|
64
|
+
for (const record of records) {
|
|
65
|
+
if (record.type === "attributes") adoptTitle(record.target);
|
|
66
|
+
else for (const node of record.addedNodes) adoptAll(node);
|
|
67
|
+
}
|
|
68
|
+
}).observe(doc.body, { subtree: true, childList: true, attributes: true, attributeFilter: ["title"] });
|
|
69
|
+
|
|
70
|
+
const hide = () => {
|
|
71
|
+
win.clearTimeout(timer);
|
|
72
|
+
if (owner) {
|
|
73
|
+
const rest = removeId(owner.getAttribute("aria-describedby"), TIP_ID);
|
|
74
|
+
if (rest) owner.setAttribute("aria-describedby", rest);
|
|
75
|
+
else owner.removeAttribute("aria-describedby");
|
|
76
|
+
}
|
|
77
|
+
owner = null;
|
|
78
|
+
tip.hidden = true;
|
|
79
|
+
};
|
|
80
|
+
const show = (el) => {
|
|
81
|
+
const text = tipText(el);
|
|
82
|
+
if (!text || !el.isConnected) return hide();
|
|
83
|
+
if (owner !== el) hide();
|
|
84
|
+
owner = el;
|
|
85
|
+
tip.textContent = text;
|
|
86
|
+
tip.style.left = "0px";
|
|
87
|
+
tip.style.top = "0px";
|
|
88
|
+
tip.hidden = false;
|
|
89
|
+
el.setAttribute("aria-describedby", addId(el.getAttribute("aria-describedby"), TIP_ID));
|
|
90
|
+
const place = placeTip(el.getBoundingClientRect(), tip.getBoundingClientRect(), { width: win.innerWidth, height: win.innerHeight });
|
|
91
|
+
tip.style.left = `${Math.round(place.left)}px`;
|
|
92
|
+
tip.style.top = `${Math.round(place.top)}px`;
|
|
93
|
+
tip.dataset.side = place.side;
|
|
94
|
+
};
|
|
95
|
+
const target = (event) => event.target instanceof win.Element ? event.target.closest(SELECTOR) : null;
|
|
96
|
+
|
|
97
|
+
doc.addEventListener("pointerover", (event) => {
|
|
98
|
+
if (event.pointerType === "touch") return;
|
|
99
|
+
const el = target(event);
|
|
100
|
+
if (el === owner) return;
|
|
101
|
+
hide();
|
|
102
|
+
if (el) timer = win.setTimeout(() => show(el), HOVER_DELAY_MS);
|
|
103
|
+
});
|
|
104
|
+
doc.addEventListener("pointerout", (event) => {
|
|
105
|
+
const el = target(event);
|
|
106
|
+
if (el && !(event.relatedTarget instanceof win.Node && el.contains(event.relatedTarget))) hide();
|
|
107
|
+
});
|
|
108
|
+
doc.addEventListener("focusin", (event) => {
|
|
109
|
+
const el = target(event);
|
|
110
|
+
if (el && event.target.matches(":focus-visible")) show(el);
|
|
111
|
+
});
|
|
112
|
+
doc.addEventListener("focusout", hide);
|
|
113
|
+
doc.addEventListener("pointerdown", hide, true);
|
|
114
|
+
doc.addEventListener("keydown", (event) => { if (event.key === "Escape" && owner) hide(); });
|
|
115
|
+
win.addEventListener("scroll", hide, true);
|
|
116
|
+
win.addEventListener("resize", hide);
|
|
117
|
+
}
|