zigmini 0.9.0-beta.3 → 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +24 -4
- package/README.md +24 -4
- package/assets/agent/gateway-rules.md +1 -1
- package/assets/android/zigmini.apk +0 -0
- package/assets/android/zigmini.apk.json +3 -3
- package/assets/build.json +1 -1
- package/docs/manual.ko.md +51 -11
- package/docs/manual.md +52 -12
- package/host/omo-bridge/agent-control.ts +274 -0
- package/host/omo-bridge/bridge.ts +1 -1
- package/host/omo-bridge/continuity-control.ts +1 -1
- package/host/omo-bridge/task-control.ts +26 -16
- package/host/omo-check/patches/omo-5.1.51-compaction-long-context.patch +92 -0
- package/host/omo-check/patches/omo-5.1.51-compaction-port.patch +2635 -0
- package/host/omo-check/patches/omo-5.1.51-compaction-recovery.patch +911 -0
- package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +363 -0
- package/host/omo-check/patches/omo-5.1.51-provider-failover.patch +50 -0
- package/host/omo-check/patches/omo-5.1.51-provider-port.patch +441 -0
- package/host/omo-check/patches/omo-5.1.51-rpc-port.patch +799 -0
- package/host/omo-check/patches/omo-5.1.51-runtime-compat.patch +31 -0
- package/package.json +3 -2
- package/src/agent/api.ts +215 -0
- package/src/agent/definition.ts +177 -0
- package/src/agent/fields.ts +42 -0
- package/src/agent/files.ts +192 -0
- package/src/agent/legacy-files.ts +46 -0
- package/src/agent/list.ts +21 -0
- package/src/agent-rules.ts +14 -5
- package/src/approvals.ts +6 -1
- package/src/cli/main.ts +33 -0
- package/src/cli/messages.ts +10 -2
- package/src/cli/rotate-main.ts +2 -1
- package/src/cli/scan.ts +414 -0
- package/src/cli/security-import.ts +155 -0
- package/src/compact.ts +22 -0
- package/src/decisions.ts +6 -4
- package/src/engine/compat.json +3 -3
- package/src/engine/index.ts +1 -0
- package/src/engine/schema-guard.ts +12 -4
- package/src/engine/sdk.ts +8 -0
- package/src/formal-verification.ts +366 -0
- package/src/gc.ts +37 -6
- package/src/hub.ts +339 -79
- package/src/main.ts +95 -9
- package/src/maintenance.ts +1 -1
- package/src/manual.ts +9 -2
- package/src/profile-prompt.ts +73 -0
- package/src/profile.ts +33 -10
- package/src/protocol.ts +11 -2
- package/src/question-notifications.ts +4 -1
- package/src/reply-notifications.ts +7 -1
- package/src/repo-security-setting.ts +33 -0
- package/src/runtime/paths.ts +16 -0
- package/src/security/baseline.ts +170 -0
- package/src/security/dex-strings.ts +242 -0
- package/src/security/repo-scan.ts +369 -0
- package/src/security/revision-probe.ts +144 -0
- package/src/security/terms.ts +97 -0
- package/src/security-allow.ts +1 -0
- package/src/security-baseline.ts +113 -0
- package/src/security-check.ts +91 -16
- package/src/server-text.ts +38 -0
- package/src/store.ts +142 -16
- package/src/transcript.ts +7 -1
- package/src/ui-language.ts +33 -0
- package/src/update/check.ts +7 -3
- package/src/update/handoff-system.ts +24 -16
- package/src/update/handoff.ts +23 -12
- package/src/update/helper.ts +72 -36
- package/src/update/request.ts +46 -30
- package/src/update/state.ts +21 -11
- package/src/update/strategy.ts +12 -5
- package/src/update/target.ts +32 -15
- package/src/usage.ts +43 -20
- package/src/version.ts +39 -16
- package/src/voice/agents.ts +8 -3
- package/src/voice/install.ts +29 -18
- package/src/work-watch.ts +40 -18
- package/web/agent-icon.js +15 -9
- package/web/agents.d.ts +201 -0
- package/web/agents.js +813 -0
- package/web/app-download.js +32 -19
- package/web/app.js +1407 -278
- package/web/autocopy.js +6 -1
- package/web/boot.js +27 -8
- package/web/commands.js +20 -11
- package/web/decisions.js +70 -49
- package/web/devlog.js +25 -19
- package/web/diagram.js +8 -3
- package/web/enroll.js +43 -22
- package/web/gc.js +32 -19
- package/web/i18n/agent-icon.js +24 -0
- package/web/i18n/agents.js +48 -0
- package/web/i18n/app-download.js +26 -0
- package/web/i18n/autocopy.js +5 -0
- package/web/i18n/boot.js +24 -0
- package/web/i18n/commands.js +34 -0
- package/web/i18n/decisions.js +24 -0
- package/web/i18n/devlog.js +60 -0
- package/web/i18n/diagram.js +12 -0
- package/web/i18n/enroll.js +22 -0
- package/web/i18n/gc.js +38 -0
- package/web/i18n/lastsent.js +10 -0
- package/web/i18n/main.d.ts +4 -0
- package/web/i18n/main.js +527 -0
- package/web/i18n/manual.d.ts +4 -0
- package/web/i18n/manual.js +14 -0
- package/web/i18n/memo.js +30 -0
- package/web/i18n/notifications.js +16 -0
- package/web/i18n/profile.js +13 -0
- package/web/i18n/reference.js +26 -0
- package/web/i18n/security-inbox.js +40 -0
- package/web/i18n/slash.js +38 -0
- package/web/i18n/turn-stats.js +5 -0
- package/web/i18n/update.js +42 -0
- package/web/i18n/voice.js +28 -0
- package/web/i18n/wake.js +16 -0
- package/web/i18n/workid.js +10 -0
- package/web/i18n/workstatus.js +20 -0
- package/web/i18n.d.ts +20 -0
- package/web/i18n.js +63 -0
- package/web/index.html +156 -96
- package/web/lastsent.js +9 -4
- package/web/manual.d.ts +1 -1
- package/web/manual.js +11 -5
- package/web/memo.js +18 -13
- package/web/notifications.js +12 -7
- package/web/profile.js +42 -17
- package/web/reference.js +20 -15
- package/web/security-inbox.d.ts +18 -1
- package/web/security-inbox.js +173 -4
- package/web/slash.js +23 -13
- package/web/style.css +132 -3
- package/web/tooltip.js +5 -2
- package/web/turn-stats.js +6 -2
- package/web/update.js +31 -23
- package/web/voice.js +18 -8
- package/web/wake.d.ts +4 -0
- package/web/wake.js +7 -3
- package/web/workid.js +12 -2
- package/web/workstatus.js +36 -32
package/src/hub.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { type TranscriptLog, toRecord } from "./transcript.ts";
|
|
|
11
11
|
import { MaintenanceError, nativeMaintenanceBlocker } from "./maintenance.ts";
|
|
12
12
|
import { isVersionCommand, VERSION_COMMAND } from "./version.ts";
|
|
13
13
|
import { isUsageCommand, USAGE_COMMAND } from "./usage.ts";
|
|
14
|
+
import { COMPACT_COMMAND, compactInstructions } from "./compact.ts";
|
|
14
15
|
import { MessageReferenceError, referencedInput, resolveMessageReferences } from "./message-references.ts";
|
|
15
16
|
import { readProfile } from "./profile.ts";
|
|
16
17
|
import type { MessageGuard, SecurityChecker } from "./security-check.ts";
|
|
@@ -18,6 +19,16 @@ import { SecurityRuntimeError, type SecurityReceipt } from "./security-allow.ts"
|
|
|
18
19
|
import { nativeReplyEntry, type NativeReplyObserver } from "./reply-notifications.ts";
|
|
19
20
|
import { notificationHistory } from "./notification-history.ts";
|
|
20
21
|
import { messageTimestamp, nativeTurnStarted, nativeTurnStats } from "./turn-stats.ts";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { mergeAgentDefinition, AgentDefinitionError, type MergedAgentDefinition } from "./agent/definition.ts";
|
|
24
|
+
import { AgentFileStore } from "./agent/files.ts";
|
|
25
|
+
import { type Language, say, serverLanguage, type Text, textIn } from "./server-text.ts";
|
|
26
|
+
|
|
27
|
+
class AgentSessionError extends Error {
|
|
28
|
+
constructor() {
|
|
29
|
+
super("agent_definition_invalid");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
21
32
|
|
|
22
33
|
export type Broadcast = (envelope: Envelope) => void;
|
|
23
34
|
|
|
@@ -41,6 +52,7 @@ const HISTORY_MAX_RESUME = 1000;
|
|
|
41
52
|
|
|
42
53
|
export interface HubOptions {
|
|
43
54
|
replies?: NativeReplyObserver;
|
|
55
|
+
agents?: AgentFileStore;
|
|
44
56
|
cwd: string;
|
|
45
57
|
sessionDir: string;
|
|
46
58
|
permissionPreset: string;
|
|
@@ -74,21 +86,37 @@ const PROMPT_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
|
|
|
74
86
|
const MAX_ATTACHMENTS = 4;
|
|
75
87
|
|
|
76
88
|
const COMPACTION_RECOVERY = {
|
|
77
|
-
invalid_model: { action: "settings", message: "요약 모델 설정을 확인하거나 compaction.model을 auto로 바꾼 뒤 다시 진행해 주세요." },
|
|
78
|
-
auth_missing: { action: "login", message: "요약에 사용하는 제공자에 로그인한 뒤 다시 진행해 주세요." },
|
|
79
|
-
auth_expired: { action: "login", message: "요약에 사용하는 제공자에 다시 로그인한 뒤 다시 진행해 주세요." },
|
|
80
|
-
quota: { action: "quota", message: "제공자의 사용 한도가 복구된 뒤 다시 진행해 주세요." },
|
|
81
|
-
org_denied: { action: "account", message: "해당 제공자의 계정과 조직 권한을 확인한 뒤 다시 진행해 주세요." },
|
|
82
|
-
model_unavailable: { action: "settings", message: "요약 모델의 사용 가능 여부와 설정을 확인한 뒤 다시 진행해 주세요." },
|
|
83
|
-
refused: { action: "retry", message: "제공자가 요약을 거절했습니다. 원인을 해결한 뒤 다시 진행해 주세요." },
|
|
84
|
-
timeout: { action: "retry", message: "요약 요청 시간이 초과됐습니다. 원인을 해결한 뒤 다시 진행해 주세요." },
|
|
85
|
-
incomplete: { action: "retry", message: "요약 응답이 완전하지 않습니다. 원인을 해결한 뒤 다시 진행해 주세요." },
|
|
86
|
-
empty: { action: "retry", message: "요약 응답이 비어 있습니다. 원인을 해결한 뒤 다시 진행해 주세요." },
|
|
87
|
-
context_budget: { action: "retry", message: "요약 결과가 대화 모델의 입력 한도에 맞지 않습니다. 원인을 해결한 뒤 다시 진행해 주세요." },
|
|
88
|
-
stale: { action: "retry", message: "요약 중 대화가 바뀌었습니다. 최신 대화에서 다시 진행해 주세요." },
|
|
89
|
-
provider: { action: "retry", message: "원인을 해결한 뒤 다시 진행해 주세요." },
|
|
90
|
-
account_blocked: { action: "account", message: "요약 공급자의 계정 묶음이 요청을 보내지 않았습니다. 원인이 사용 한도인지 인증인지는 확정되지 않았습니다. 계정 상태를 확인하거나 다른 모델을 고른 뒤 다시 진행해 주세요." },
|
|
91
|
-
} as const satisfies Record<CompactionFailureKind, { readonly action: string; readonly message:
|
|
89
|
+
invalid_model: { action: "settings", message: { en: "Check the summary model setting or change compaction.model to auto, then try again.", ko: "요약 모델 설정을 확인하거나 compaction.model을 auto로 바꾼 뒤 다시 진행해 주세요." } },
|
|
90
|
+
auth_missing: { action: "login", message: { en: "Sign in to the provider used for summaries, then try again.", ko: "요약에 사용하는 제공자에 로그인한 뒤 다시 진행해 주세요." } },
|
|
91
|
+
auth_expired: { action: "login", message: { en: "Sign in again to the provider used for summaries, then try again.", ko: "요약에 사용하는 제공자에 다시 로그인한 뒤 다시 진행해 주세요." } },
|
|
92
|
+
quota: { action: "quota", message: { en: "Try again after the provider's usage limit resets.", ko: "제공자의 사용 한도가 복구된 뒤 다시 진행해 주세요." } },
|
|
93
|
+
org_denied: { action: "account", message: { en: "Check the provider account and its organization permissions, then try again.", ko: "해당 제공자의 계정과 조직 권한을 확인한 뒤 다시 진행해 주세요." } },
|
|
94
|
+
model_unavailable: { action: "settings", message: { en: "Check that the summary model is available and its settings are correct, then try again.", ko: "요약 모델의 사용 가능 여부와 설정을 확인한 뒤 다시 진행해 주세요." } },
|
|
95
|
+
refused: { action: "retry", message: { en: "The provider refused the summary. Fix the cause, then try again.", ko: "제공자가 요약을 거절했습니다. 원인을 해결한 뒤 다시 진행해 주세요." } },
|
|
96
|
+
timeout: { action: "retry", message: { en: "The summary request timed out. Fix the cause, then try again.", ko: "요약 요청 시간이 초과됐습니다. 원인을 해결한 뒤 다시 진행해 주세요." } },
|
|
97
|
+
incomplete: { action: "retry", message: { en: "The summary response was incomplete. Fix the cause, then try again.", ko: "요약 응답이 완전하지 않습니다. 원인을 해결한 뒤 다시 진행해 주세요." } },
|
|
98
|
+
empty: { action: "retry", message: { en: "The summary response was empty. Fix the cause, then try again.", ko: "요약 응답이 비어 있습니다. 원인을 해결한 뒤 다시 진행해 주세요." } },
|
|
99
|
+
context_budget: { action: "retry", message: { en: "The summary does not fit the chat model's input limit. Fix the cause, then try again.", ko: "요약 결과가 대화 모델의 입력 한도에 맞지 않습니다. 원인을 해결한 뒤 다시 진행해 주세요." } },
|
|
100
|
+
stale: { action: "retry", message: { en: "The conversation changed during the summary. Try again from the latest conversation.", ko: "요약 중 대화가 바뀌었습니다. 최신 대화에서 다시 진행해 주세요." } },
|
|
101
|
+
provider: { action: "retry", message: { en: "Fix the cause, then try again.", ko: "원인을 해결한 뒤 다시 진행해 주세요." } },
|
|
102
|
+
account_blocked: { action: "account", message: { en: "The summary provider's account pool did not send the request. It is not confirmed whether the cause is the usage limit or authentication. Check the account status or choose another model, then try again.", ko: "요약 공급자의 계정 묶음이 요청을 보내지 않았습니다. 원인이 사용 한도인지 인증인지는 확정되지 않았습니다. 계정 상태를 확인하거나 다른 모델을 고른 뒤 다시 진행해 주세요." } },
|
|
103
|
+
} as const satisfies Record<CompactionFailureKind, { readonly action: string; readonly message: Text }>;
|
|
104
|
+
|
|
105
|
+
/** Improvement 230: why a `/compact` was refused and what to do; a refused `/compact` is never queued. */
|
|
106
|
+
const COMPACT_REFUSED = {
|
|
107
|
+
engine: { en: "The conversation was not summarized because the engine is not connected. Send /compact again after the connection returns.", ko: "엔진에 연결되지 않아 대화를 요약하지 않았습니다. 연결이 돌아온 뒤 /compact를 다시 보내 주세요." },
|
|
108
|
+
session: { en: "The conversation was not summarized because the session is being switched. Send /compact again after the switch finishes.", ko: "세션을 교체하고 있어 대화를 요약하지 않았습니다. 교체가 끝난 뒤 /compact를 다시 보내 주세요." },
|
|
109
|
+
compacting: { en: "The conversation is already being summarized. If needed, send /compact again after the summary finishes.", ko: "이미 대화를 요약하고 있습니다. 요약이 끝난 뒤 필요하면 /compact를 다시 보내 주세요." },
|
|
110
|
+
retrying: { en: "The conversation was not summarized because a model request is being retried. Send /compact again after the retry finishes.", ko: "모델 요청을 재시도하고 있어 대화를 요약하지 않았습니다. 재시도가 끝난 뒤 /compact를 다시 보내 주세요." },
|
|
111
|
+
answering: { en: "The conversation was not summarized because an answer is in progress. Send /compact again after the answer finishes.", ko: "답변이 진행 중이라 대화를 요약하지 않았습니다. 답변이 끝난 뒤 /compact를 다시 보내 주세요." },
|
|
112
|
+
} as const satisfies Record<string, Text>;
|
|
113
|
+
/** A compaction failure notice: what failed, then the recovery text of COMPACTION_RECOVERY. */
|
|
114
|
+
const COMPACTION_FAILED: Text = { en: "Could not summarize the conversation. The earlier content was kept. {recovery}",
|
|
115
|
+
ko: "대화 요약에 실패했습니다. 이전 내용은 유지했습니다. {recovery}" };
|
|
116
|
+
/** Improvement 230: the engine refused `/compact` and no terminal compaction event reported it. */
|
|
117
|
+
function compactNotReported(): string {
|
|
118
|
+
return say(COMPACTION_FAILED, { recovery: say(COMPACTION_RECOVERY.provider.message) });
|
|
119
|
+
}
|
|
92
120
|
|
|
93
121
|
/** Bug 223: the longest provider error a compaction failure log line keeps, after its sensitive values are removed. */
|
|
94
122
|
export const COMPACTION_ERROR_MAX_CHARS = 512;
|
|
@@ -204,7 +232,65 @@ function describeBlockers(blockers: readonly RotationBlocker[]): string {
|
|
|
204
232
|
const HANDOFF_MAX_CHARS = 8000;
|
|
205
233
|
const HANDOFF_LINE_CHARS = 600;
|
|
206
234
|
const CONTEXT_OVERFLOW = /prompt is too long|context overflow/i;
|
|
207
|
-
const COMPACTION_REQUIRED_MESSAGE =
|
|
235
|
+
const COMPACTION_REQUIRED_MESSAGE: Text = {
|
|
236
|
+
en: "The engine did not receive this message because the conversation summary did not finish. The message stays in the history and will not be sent again automatically. Fix the cause of the summary failure or press 'New session'.",
|
|
237
|
+
ko: "대화 요약이 끝나지 않아 엔진이 이 메시지를 받지 않았습니다. 메시지는 기록에 남아 있고 자동으로 다시 보내지 않습니다. 요약 실패 원인을 해결하거나 '새 세션'을 눌러 주세요.",
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
/** Improvement 253: fixed hub text people read in the chat (notices, errors, cancel reasons, labels), chosen by say() when made. */
|
|
241
|
+
export const HUB_TEXT = {
|
|
242
|
+
newConversation: { en: "New conversation", ko: "새 대화" },
|
|
243
|
+
engineDisconnected: { en: "The engine connection was lost.", ko: "엔진 연결이 끊겼습니다." },
|
|
244
|
+
recoveryFailed: { en: "Could not recover the previous work: {error}", ko: "이전 작업 복구 실패: {error}" },
|
|
245
|
+
closeChecking: { en: "Checking whether the session closed. The message will be delivered after the check.", ko: "세션 닫기 상태를 확인하고 있습니다. 메시지는 확인 후 전달합니다." },
|
|
246
|
+
workNotifyFailed: { en: "Could not send the work result notification: {error}", ko: "작업 결과 알림 실패: {error}" },
|
|
247
|
+
workReceiptUnknown: { en: "Could not confirm that the work result notification was received. The result record is kept.", ko: "작업 결과 알림의 수신을 확인하지 못했습니다. 결과 기록은 보존되어 있습니다." },
|
|
248
|
+
workReconcileFailed: { en: "Could not check the work result notifications: {error}", ko: "작업 결과 알림 대조 실패: {error}" },
|
|
249
|
+
inputQueued: { en: "The message is still in the engine queue. It will not be sent again automatically.", ko: "메시지가 엔진 대기열에 남아 있습니다. 자동 재전송하지 않습니다." },
|
|
250
|
+
inputConsumptionUnknown: { en: "Could not confirm that the message was processed. The record is kept, and the message will not be sent again automatically.", ko: "메시지의 실행 소비를 확인하지 못했습니다. 기록을 보존하며 자동 재전송하지 않습니다." },
|
|
251
|
+
queuedResumeFailed: { en: "Could not confirm that the queued input resumed: {error}", ko: "대기 입력 재개 확인 실패: {error}" },
|
|
252
|
+
userInputReconcileFailed: { en: "Could not check the user input: {error}", ko: "사용자 입력 대조 실패: {error}" },
|
|
253
|
+
sessionTransition: { en: "Switching session", ko: "세션 교체 중" },
|
|
254
|
+
sessionRotated: { en: "Switched to a new engine session ({reason}). The chat history on the screen stays as it is, and the next message continues in the new session.{extra}",
|
|
255
|
+
ko: "새 엔진 세션으로 교체했습니다 ({reason}). 화면의 대화 기록은 그대로이고, 다음 메시지부터 새 세션에서 이어집니다.{extra}" },
|
|
256
|
+
rotatedAfterFailure: { en: " The previous session file was kept, and messages the engine did not receive will not be sent again automatically.", ko: " 이전 세션 파일은 그대로 남겨 두었고, 받지 못한 메시지는 자동으로 다시 보내지 않습니다." },
|
|
257
|
+
closeResolved: { en: "Checked whether the session closed. Messages continue in the existing conversation.", ko: "세션 닫기 상태를 확인했습니다. 기존 대화에서 메시지 처리를 계속합니다." },
|
|
258
|
+
closeRecoveryExhausted: { en: "session_close_recovery_exhausted: {error}. The message is kept. Check the engine connection and the session list, then restart the gateway.",
|
|
259
|
+
ko: "session_close_recovery_exhausted: {error}. 메시지는 보관 중입니다. 엔진 연결과 세션 목록을 확인한 뒤 게이트웨이를 다시 시작하세요." },
|
|
260
|
+
rotationQuestionClosed: { en: "This question was closed by a session switch. If you answer it in the inbox, the answer goes to the new session.", ko: "세션 교체로 닫힌 질문입니다. 받은편지함에서 답하면 새 세션으로 전달됩니다." },
|
|
261
|
+
continuityProtected: { en: "{message}. The same engine session is kept. Fix the cause, then send an instruction to continue. The earlier input will not be sent again automatically.",
|
|
262
|
+
ko: "{message}. 같은 엔진 세션을 유지합니다. 원인을 해결한 뒤 계속할 지시를 보내 주세요. 이전 입력은 자동 재전송하지 않습니다." },
|
|
263
|
+
userStopped: { en: "The user stopped the task.", ko: "사용자가 작업을 중단했습니다." },
|
|
264
|
+
stopped: { en: "Stopped the task.", ko: "작업을 중단했습니다." },
|
|
265
|
+
modelResponseError: { en: "Model response error", ko: "모델 응답 오류" },
|
|
266
|
+
compactionStarted: { en: "Summarizing the conversation. Please wait until it finishes.", ko: "대화 내용을 요약하고 있습니다. 완료될 때까지 기다려 주세요." },
|
|
267
|
+
compactionCancelled: { en: "Cancelled the conversation summary. The earlier content and the same session are kept. The earlier input will not be sent again automatically.",
|
|
268
|
+
ko: "대화 요약을 취소했습니다. 이전 내용과 같은 세션을 유지합니다. 이전 입력은 자동으로 다시 보내지 않습니다." },
|
|
269
|
+
compactionCompleted: { en: "Finished summarizing the conversation. Continuing in the same session.", ko: "대화 요약을 완료했습니다. 같은 세션에서 계속합니다." },
|
|
270
|
+
turnEnded: { en: "The task finished.", ko: "작업이 끝났습니다." },
|
|
271
|
+
permissionTitle: { en: "Permission request: {tool}", ko: "권한 요청: {tool}" },
|
|
272
|
+
endedWhileOff: { en: "This question ended while the gateway was off.", ko: "게이트웨이가 꺼져 있던 동안 끝난 질문입니다." },
|
|
273
|
+
reattached: { en: "The gateway reconnected and continues to receive the work in progress.{recovered}", ko: "게이트웨이가 다시 연결돼 진행 중인 작업을 이어서 받습니다.{recovered}" },
|
|
274
|
+
sendReceiptUnknown: { en: "Could not confirm that the message was received after it was sent. To avoid running it twice, it will not be sent again automatically.",
|
|
275
|
+
ko: "메시지 전송 이후 수신을 확인하지 못했습니다. 중복 실행을 피하기 위해 자동 재전송하지 않습니다." },
|
|
276
|
+
recovered: { en: "Fetched the work results that finished while the gateway was off.{recovered}", ko: "게이트웨이가 꺼져 있던 동안 끝난 작업 결과를 가져왔습니다.{recovered}" },
|
|
277
|
+
interrupted: { en: "The work was interrupted. It was continued automatically {tries} times but did not finish. Check it, then give the instruction again.",
|
|
278
|
+
ko: "작업이 끊겨 자동으로 {tries}번 이어 보냈지만 끝나지 않았습니다. 확인 후 다시 지시해 주세요." },
|
|
279
|
+
autoContinue: { en: "Continuing the work a restart interrupted ({attempt}/{limit}).{recovered}", ko: "재시작으로 끊긴 작업을 이어서 진행합니다 ({attempt}/{limit}).{recovered}" },
|
|
280
|
+
recoveredOne: { en: " Filled in 1 missed answer.", ko: " 놓친 답변 1개를 채웠습니다." },
|
|
281
|
+
recoveredMany: { en: " Filled in {count} missed answers.", ko: " 놓친 답변 {count}개를 채웠습니다." },
|
|
282
|
+
multiSelect: { en: " (multiple choices allowed)", ko: " (여러 개 선택 가능)" },
|
|
283
|
+
questionUnreachable: { en: "The question window did not reach the web. Press Stop, then answer in the chat.", ko: "질문 창이 웹에 도착하지 않았습니다. 중단 버튼을 누른 뒤 채팅으로 답해 주세요." },
|
|
284
|
+
} as const satisfies Record<string, Text>;
|
|
285
|
+
|
|
286
|
+
/** Why a session was replaced. The user notice uses the UI language; the engine context keeps the Korean text. */
|
|
287
|
+
const ROTATION_REASON = {
|
|
288
|
+
size: { en: "the session file reached {mb} MB", ko: "세션 파일이 {mb}MB에 닿음" },
|
|
289
|
+
compactions: { en: "compaction ran {count} more times", ko: "압축이 {count}번 더 이어짐" },
|
|
290
|
+
compaction_failed: { en: "the conversation summary failed {count} times in a row", ko: "대화 요약이 {count}번 연속 실패함" },
|
|
291
|
+
manual: { en: "requested directly", ko: "직접 요청" },
|
|
292
|
+
age: { en: "the session passed {hours} hours and is idle", ko: "세션이 {hours}시간을 넘겨 쉬는 중" },
|
|
293
|
+
} as const satisfies Record<string, Text>;
|
|
208
294
|
|
|
209
295
|
interface OpenResult {
|
|
210
296
|
sessionId: string;
|
|
@@ -234,6 +320,11 @@ export interface SlashCommand {
|
|
|
234
320
|
source: string;
|
|
235
321
|
}
|
|
236
322
|
|
|
323
|
+
/** An agent session's command list (D5): skill commands outside its `skills` allowlist are left out; other sources stay. */
|
|
324
|
+
export function allowedAgentCommands<T extends { readonly name: string; readonly source: string }>(commands: readonly T[], skills: ReadonlySet<string>): T[] {
|
|
325
|
+
return commands.filter((command) => command.source !== "skill" || skills.has(command.name) || skills.has(command.name.replace(/^skill:/, "")));
|
|
326
|
+
}
|
|
327
|
+
|
|
237
328
|
interface WorkWake {
|
|
238
329
|
input: StoredEvent;
|
|
239
330
|
/** Releases a live receipt wait after reconciliation or connection loss. Absent after restart. */
|
|
@@ -381,6 +472,7 @@ export function modelOptions(models: OmoModel[]): ModelChoice[] {
|
|
|
381
472
|
|
|
382
473
|
export class Hub {
|
|
383
474
|
private readonly runtime = new Map<string, ConvRuntime>();
|
|
475
|
+
private readonly deletingAgents = new Set<string>();
|
|
384
476
|
private readonly sessionToConv = new Map<string, string>();
|
|
385
477
|
/** Open ask_user_question cards by scope-qualified gateway action ID. omo owns their lifetime. */
|
|
386
478
|
private readonly questions = new Map<string, QuestionCard>();
|
|
@@ -676,9 +768,10 @@ export class Hub {
|
|
|
676
768
|
}
|
|
677
769
|
|
|
678
770
|
snapshot(): Record<string, unknown> {
|
|
771
|
+
const conversations = this.store.listConversations(false);
|
|
679
772
|
return {
|
|
680
|
-
conversations
|
|
681
|
-
activity: Object.fromEntries(
|
|
773
|
+
conversations,
|
|
774
|
+
activity: Object.fromEntries(conversations.map((conv) => [conv.id, this.activity(conv.id)])),
|
|
682
775
|
approvals: [...this.dialogs.list(), ...this.questions.values(), ...this.releasedMainCards()],
|
|
683
776
|
sessionTransitions: [...this.runtime]
|
|
684
777
|
.filter(([, runtime]) => runtime.rotating)
|
|
@@ -720,6 +813,8 @@ export class Hub {
|
|
|
720
813
|
private modelPending = false;
|
|
721
814
|
private readonly knownModels = new Set<string>();
|
|
722
815
|
private providerErrorChecker: SecurityChecker | null = null;
|
|
816
|
+
/** Improvement 230: conversations whose `/compact` RPC has not answered yet. */
|
|
817
|
+
private readonly manualCompactions = new Set<string>();
|
|
723
818
|
|
|
724
819
|
/** The models omo can use right now (its logged-in providers) and the one a conversation's session is on. */
|
|
725
820
|
async models(convId = "main"): Promise<ModelInfo> {
|
|
@@ -787,13 +882,43 @@ export class Hub {
|
|
|
787
882
|
}
|
|
788
883
|
}
|
|
789
884
|
|
|
790
|
-
createConversation(title: string): ConversationInfo {
|
|
791
|
-
const conv = this.store.createConversation(this.nextId("c"), title.trim() ||
|
|
885
|
+
createConversation(title: string, agentId?: string): ConversationInfo {
|
|
886
|
+
const conv = this.store.createConversation(this.nextId("c"), title.trim() || say(HUB_TEXT.newConversation), agentId);
|
|
792
887
|
this.emit("*", "conversation.created", conv);
|
|
793
888
|
this.publishActivity(conv.id);
|
|
794
889
|
return conv;
|
|
795
890
|
}
|
|
796
891
|
|
|
892
|
+
agentUpdated(data: unknown): void {
|
|
893
|
+
this.emit("*", "agent.updated", data);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/** Keep file movement and visibility changes inside the same deletion admission. */
|
|
897
|
+
async deleteAgentConversation(convId: string, trash: () => string): Promise<string> {
|
|
898
|
+
const conv = this.store.getConversation(convId);
|
|
899
|
+
if (!conv?.agentId) throw new Error("agent_conversation_missing");
|
|
900
|
+
const rt = this.rt(convId);
|
|
901
|
+
if (this.maintenanceId || this.deletingAgents.has(convId) || rt.rotating || this.closeUncertain(convId) ||
|
|
902
|
+
this.hasUnfinishedTurn(convId) || this.rotationBlocked(convId) ||
|
|
903
|
+
this.openQuestionCards(convId, this.conversationScope(convId)).size > 0) throw new Error("agent_busy");
|
|
904
|
+
this.deletingAgents.add(convId);
|
|
905
|
+
try {
|
|
906
|
+
if (conv.threadId || rt.sessionId) {
|
|
907
|
+
const { sessionId } = await this.ensureSession(conv, true);
|
|
908
|
+
await this.requireOmo().command("close_session", { sessionId });
|
|
909
|
+
this.sessionToConv.delete(sessionId);
|
|
910
|
+
const owner = this.ownerForConversation(convId, sessionId);
|
|
911
|
+
if (owner) this.releaseScopedUiOwner(owner);
|
|
912
|
+
rt.sessionId = null;
|
|
913
|
+
}
|
|
914
|
+
const entry = trash();
|
|
915
|
+
this.store.setConversationHidden(convId, true);
|
|
916
|
+
return entry;
|
|
917
|
+
} finally {
|
|
918
|
+
this.deletingAgents.delete(convId);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
797
922
|
onOmoStatus(status: OmoStatus, detail?: string): Promise<number> {
|
|
798
923
|
const activityRecovery = this.beginActivityRecovery();
|
|
799
924
|
this.omoStatus = status;
|
|
@@ -821,7 +946,7 @@ export class Hub {
|
|
|
821
946
|
rt.wake = null;
|
|
822
947
|
rt.notified.clear();
|
|
823
948
|
const scope = this.conversationScope(conv);
|
|
824
|
-
this.dialogs.cancelScope(scope,
|
|
949
|
+
this.dialogs.cancelScope(scope, say(HUB_TEXT.engineDisconnected));
|
|
825
950
|
for (const [id, card] of this.questions) {
|
|
826
951
|
if (card.scope !== scope) continue;
|
|
827
952
|
this.questions.delete(id);
|
|
@@ -884,7 +1009,7 @@ export class Hub {
|
|
|
884
1009
|
this.activityRecoveryFailedGeneration = activityRecovery;
|
|
885
1010
|
this.publishAllActivities();
|
|
886
1011
|
}
|
|
887
|
-
this.emit(conv.id, "error", { message:
|
|
1012
|
+
if (!(error instanceof AgentSessionError)) this.emit(conv.id, "error", { message: say(HUB_TEXT.recoveryFailed, { error: errorMessage(error) }) });
|
|
888
1013
|
}
|
|
889
1014
|
}
|
|
890
1015
|
}
|
|
@@ -892,6 +1017,7 @@ export class Hub {
|
|
|
892
1017
|
/** `images` are names of files already uploaded to the media folder; unknown names are dropped. */
|
|
893
1018
|
async sendText(convId: string, text: string, src: Source, trust: Trust, images: string[] = [], referenceIds?: unknown, checked = false, submissionId?: string): Promise<void> {
|
|
894
1019
|
if (this.maintenanceId) throw new MaintenanceError("maintenance_held");
|
|
1020
|
+
this.requireVisibleAgent(convId);
|
|
895
1021
|
if (submissionId !== undefined && !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(submissionId)) throw new SecurityRuntimeError("held_conflict");
|
|
896
1022
|
const context = { ...(submissionId ? { key: JSON.stringify(["message", convId, submissionId]), submissionId } : {}),
|
|
897
1023
|
snapshot: structuredClone({ text, src, trust, images, referenceIds }) };
|
|
@@ -909,6 +1035,7 @@ export class Hub {
|
|
|
909
1035
|
this.emit(convId, "notice", { kind: "usage", level: "info", message: await this.options.usageReport() });
|
|
910
1036
|
return;
|
|
911
1037
|
}
|
|
1038
|
+
const compact = images.length === 0 && references.length === 0 ? compactInstructions(body) : null;
|
|
912
1039
|
const dir = this.options.media?.dir;
|
|
913
1040
|
const attached = dir ? [...new Set(images)].filter((n) => mediaFile(dir, n)).slice(0, MAX_ATTACHMENTS) : [];
|
|
914
1041
|
const prompt = referencedInput(body, attached, references);
|
|
@@ -919,13 +1046,17 @@ export class Hub {
|
|
|
919
1046
|
if (!body && attached.length === 0 && references.length === 0) return;
|
|
920
1047
|
const attachmentSnapshot = this.attachments(prompt.images);
|
|
921
1048
|
const send = async (receipt?: (ref: SecurityReceipt) => void) => {
|
|
1049
|
+
this.requireVisibleAgent(convId);
|
|
922
1050
|
if (receipt && prompt.images.some((name) => !dir || !mediaFile(dir, name))) throw new SecurityRuntimeError("dispatch_failed");
|
|
1051
|
+
// A message stored now would cancel the session replacement in progress (it admits no new user message).
|
|
1052
|
+
if (compact !== null && this.rt(convId).rotating) return this.refuseCompact(convId, COMPACT_REFUSED.session, receipt !== undefined);
|
|
923
1053
|
const input = this.store.acceptMainUser(convId, {
|
|
924
1054
|
text: body, src: source, trust, ...(attached.length ? { images: attached } : {}),
|
|
925
1055
|
...(references.length ? { references } : {}),
|
|
926
|
-
}, receipt);
|
|
1056
|
+
}, receipt, compact !== null);
|
|
927
1057
|
this.publishStored(input);
|
|
928
1058
|
this.decisions?.refresh();
|
|
1059
|
+
if (compact !== null) return this.manualCompact(convId, input.seq, compact, receipt !== undefined);
|
|
929
1060
|
const rotationDone = this.rt(convId).rotationDone;
|
|
930
1061
|
if (rotationDone) await rotationDone;
|
|
931
1062
|
if (this.closeUncertain(convId)) {
|
|
@@ -935,7 +1066,7 @@ export class Hub {
|
|
|
935
1066
|
}
|
|
936
1067
|
this.scheduleCloseRecovery(convId);
|
|
937
1068
|
this.emit(convId, "notice", { kind: "session-close-recovery", level: "info",
|
|
938
|
-
message:
|
|
1069
|
+
message: say(HUB_TEXT.closeChecking) });
|
|
939
1070
|
return;
|
|
940
1071
|
}
|
|
941
1072
|
await this.prompt(convId, prompt.text, prompt.images, input.seq, true, attachmentSnapshot, receipt !== undefined);
|
|
@@ -992,7 +1123,7 @@ export class Hub {
|
|
|
992
1123
|
const generation = this.generation;
|
|
993
1124
|
const flight = this.injectWorkUpdates(convId, generation).catch((error) => {
|
|
994
1125
|
if (this.omoStatus === "ready" && generation === this.generation) {
|
|
995
|
-
this.emit(convId, "error", { message:
|
|
1126
|
+
this.emit(convId, "error", { message: say(HUB_TEXT.workNotifyFailed, { error: errorMessage(error) }) });
|
|
996
1127
|
}
|
|
997
1128
|
}).finally(() => {
|
|
998
1129
|
if (rt.waking !== flight) return;
|
|
@@ -1054,7 +1185,7 @@ export class Hub {
|
|
|
1054
1185
|
if (!acknowledged && generation === this.generation && this.omoStatus === "ready" && rt.wake === wake) {
|
|
1055
1186
|
this.emit(convId, "notice", {
|
|
1056
1187
|
kind: "work-input-receipt-unknown", level: "warning",
|
|
1057
|
-
input_seq: input.seq, message:
|
|
1188
|
+
input_seq: input.seq, message: say(HUB_TEXT.workReceiptUnknown),
|
|
1058
1189
|
});
|
|
1059
1190
|
}
|
|
1060
1191
|
}
|
|
@@ -1099,7 +1230,7 @@ export class Hub {
|
|
|
1099
1230
|
}
|
|
1100
1231
|
} catch (error) {
|
|
1101
1232
|
if (generation === this.generation && this.omoStatus === "ready") {
|
|
1102
|
-
this.emit(conv, "error", { message:
|
|
1233
|
+
this.emit(conv, "error", { message: say(HUB_TEXT.workReconcileFailed, { error: errorMessage(error) }) });
|
|
1103
1234
|
}
|
|
1104
1235
|
}
|
|
1105
1236
|
if (generation !== this.generation || this.omoStatus !== "ready" || rt.sessionId !== sessionId || rt.run !== run) return;
|
|
@@ -1119,7 +1250,7 @@ export class Hub {
|
|
|
1119
1250
|
await this.reconcileWorkInput(pendingWake.input, sessionId, generation, true);
|
|
1120
1251
|
} catch (error) {
|
|
1121
1252
|
if (generation === this.generation && this.omoStatus === "ready") {
|
|
1122
|
-
this.emit(conv, "error", { message:
|
|
1253
|
+
this.emit(conv, "error", { message: say(HUB_TEXT.workReconcileFailed, { error: errorMessage(error) }) });
|
|
1123
1254
|
}
|
|
1124
1255
|
}
|
|
1125
1256
|
if (generation !== this.generation || this.omoStatus !== "ready" || rt.sessionId !== sessionId || rt.run !== run) return;
|
|
@@ -1180,8 +1311,7 @@ export class Hub {
|
|
|
1180
1311
|
&& (event.data as { input_seq?: number }).input_seq === input.seq).at(-1);
|
|
1181
1312
|
if ((previous?.data as { kind?: string } | undefined)?.kind !== kind) this.emit(conv, "notice", {
|
|
1182
1313
|
kind, input_seq: input.seq, level: "warning",
|
|
1183
|
-
message: queued ?
|
|
1184
|
-
: "메시지의 실행 소비를 확인하지 못했습니다. 기록을 보존하며 자동 재전송하지 않습니다.",
|
|
1314
|
+
message: say(queued ? HUB_TEXT.inputQueued : HUB_TEXT.inputConsumptionUnknown),
|
|
1185
1315
|
});
|
|
1186
1316
|
if (queued && data.native_identity && rt.atomicQueuedResume && state &&
|
|
1187
1317
|
state.isStreaming === false && typeof state.queueEpoch === "string") {
|
|
@@ -1192,7 +1322,7 @@ export class Hub {
|
|
|
1192
1322
|
sessionId, clientInputId: data.input_id, queueEpoch: state.queueEpoch,
|
|
1193
1323
|
}).catch((error) => {
|
|
1194
1324
|
if (generation === this.generation && this.omoStatus === "ready" && rt.sessionId === sessionId) {
|
|
1195
|
-
this.emit(conv, "error", { message:
|
|
1325
|
+
this.emit(conv, "error", { message: say(HUB_TEXT.queuedResumeFailed, { error: errorMessage(error) }) });
|
|
1196
1326
|
}
|
|
1197
1327
|
});
|
|
1198
1328
|
}
|
|
@@ -1216,7 +1346,7 @@ export class Hub {
|
|
|
1216
1346
|
}
|
|
1217
1347
|
} catch (error) {
|
|
1218
1348
|
if (generation === this.generation && this.omoStatus === "ready" && rt.sessionId === sessionId) {
|
|
1219
|
-
this.emit(conv, "error", { message:
|
|
1349
|
+
this.emit(conv, "error", { message: say(HUB_TEXT.userInputReconcileFailed, { error: errorMessage(error) }) });
|
|
1220
1350
|
}
|
|
1221
1351
|
} finally {
|
|
1222
1352
|
clearTimeout(timer);
|
|
@@ -1243,6 +1373,7 @@ export class Hub {
|
|
|
1243
1373
|
*/
|
|
1244
1374
|
async rotateSession(convId: string, reason: RotationReason): Promise<ConversationInfo> {
|
|
1245
1375
|
if (this.maintenanceId) throw new MaintenanceError("maintenance_held");
|
|
1376
|
+
this.requireVisibleAgent(convId);
|
|
1246
1377
|
const conv = this.store.getConversation(convId);
|
|
1247
1378
|
if (!conv) throw new Error(`unknown_conversation:${convId}`);
|
|
1248
1379
|
const rt = this.rt(convId);
|
|
@@ -1258,7 +1389,7 @@ export class Hub {
|
|
|
1258
1389
|
rt.rotating = true;
|
|
1259
1390
|
rt.rotationDone = latch.promise;
|
|
1260
1391
|
rt.finishRotation = latch.resolve;
|
|
1261
|
-
this.emit(convId, "session.transition", { active: true, label:
|
|
1392
|
+
this.emit(convId, "session.transition", { active: true, label: say(HUB_TEXT.sessionTransition) }, false);
|
|
1262
1393
|
try {
|
|
1263
1394
|
const { sessionId } = await this.ensureSession(conv, true);
|
|
1264
1395
|
const readiness = await this.requireOmo().command<{
|
|
@@ -1382,7 +1513,7 @@ export class Hub {
|
|
|
1382
1513
|
const from = conv.threadId;
|
|
1383
1514
|
const sessionId = rt.sessionId;
|
|
1384
1515
|
// Synchronous from the check above to the new thread, so no prompt or rotation interleaves; no latch is needed.
|
|
1385
|
-
this.emit(convId, "session.transition", { active: true, label:
|
|
1516
|
+
this.emit(convId, "session.transition", { active: true, label: say(HUB_TEXT.sessionTransition) }, false);
|
|
1386
1517
|
try {
|
|
1387
1518
|
if (sessionId) this.retireScopedQuestions(convId, sessionId);
|
|
1388
1519
|
rt.running = false;
|
|
@@ -1425,14 +1556,17 @@ export class Hub {
|
|
|
1425
1556
|
return this.options.rotation?.freshSessionCompactionFailures ?? FRESH_SESSION_COMPACTION_FAILURES;
|
|
1426
1557
|
}
|
|
1427
1558
|
|
|
1428
|
-
/**
|
|
1559
|
+
/**
|
|
1560
|
+
* Bug 224: failed blocking compactions since the last completed one in this session, one per native request id.
|
|
1561
|
+
* Improvement 230: a failed manual summary (`/compact`) does not count.
|
|
1562
|
+
*/
|
|
1429
1563
|
private compactionFailuresInRow(conv: string): number {
|
|
1430
1564
|
const failed = new Set<string>();
|
|
1431
1565
|
for (const event of this.store.eventsAfter(conv, this.store.lastSeq(conv, "session.rotated")).reverse()) {
|
|
1432
1566
|
if (event.type !== "session.compaction") continue;
|
|
1433
|
-
const data = event.data as { status?: string; requestId?: unknown };
|
|
1567
|
+
const data = event.data as { status?: string; requestId?: unknown; reason?: unknown };
|
|
1434
1568
|
if (data.status === "completed") break;
|
|
1435
|
-
if (data.status === "failed" && typeof data.requestId === "string") failed.add(data.requestId);
|
|
1569
|
+
if (data.status === "failed" && data.reason !== "manual" && typeof data.requestId === "string") failed.add(data.requestId);
|
|
1436
1570
|
}
|
|
1437
1571
|
return failed.size;
|
|
1438
1572
|
}
|
|
@@ -1474,8 +1608,8 @@ export class Hub {
|
|
|
1474
1608
|
this.emit(convId, "conversation.thread", { threadId: to });
|
|
1475
1609
|
this.emit(convId, "session.rotated", { reason: retirement.reason, from: retirement.from, to });
|
|
1476
1610
|
this.emit(convId, "notice", { kind: "session-rotated", level: "info",
|
|
1477
|
-
message:
|
|
1478
|
-
retirement.reason === "compaction_failed" ?
|
|
1611
|
+
message: say(HUB_TEXT.sessionRotated, { reason: this.reasonLabel(retirement.reason, serverLanguage()),
|
|
1612
|
+
extra: retirement.reason === "compaction_failed" ? say(HUB_TEXT.rotatedAfterFailure) : "" }) });
|
|
1479
1613
|
}
|
|
1480
1614
|
|
|
1481
1615
|
private closeTimer(callback: () => void, ms: number): ReturnType<typeof setTimeout> {
|
|
@@ -1545,14 +1679,14 @@ export class Hub {
|
|
|
1545
1679
|
this.emit(convId, "session.close.resolved", { sessionId: data.sessionId, sessionPath: data.sessionPath,
|
|
1546
1680
|
outcome: present ? "retained" : "superseded", attempts: recovery.attempts });
|
|
1547
1681
|
this.emit(convId, "notice", { kind: "session-close-resolved", level: "info",
|
|
1548
|
-
message:
|
|
1682
|
+
message: say(HUB_TEXT.closeResolved) });
|
|
1549
1683
|
}
|
|
1550
1684
|
this.closeRecoveries.delete(convId);
|
|
1551
1685
|
} catch (error) {
|
|
1552
1686
|
if (generation !== this.generation) return;
|
|
1553
1687
|
if (recovery.attempts >= (this.options.closeRecovery?.maxAttempts ?? 3)) {
|
|
1554
1688
|
recovery.exhausted = true;
|
|
1555
|
-
this.emit(convId, "error", { message:
|
|
1689
|
+
this.emit(convId, "error", { message: say(HUB_TEXT.closeRecoveryExhausted, { error: errorMessage(error) }) });
|
|
1556
1690
|
}
|
|
1557
1691
|
} finally {
|
|
1558
1692
|
if (timeout !== undefined) this.clearCloseTimer(timeout);
|
|
@@ -1715,7 +1849,7 @@ export class Hub {
|
|
|
1715
1849
|
this.legacyQuestionHistoryAliases.delete(id);
|
|
1716
1850
|
this.emit(conv, "action.resolved", {
|
|
1717
1851
|
actionId: historyActionId,
|
|
1718
|
-
answer: { kind: "cancel", reason:
|
|
1852
|
+
answer: { kind: "cancel", reason: say(HUB_TEXT.rotationQuestionClosed) },
|
|
1719
1853
|
by: "cancelled",
|
|
1720
1854
|
});
|
|
1721
1855
|
}
|
|
@@ -1748,12 +1882,13 @@ export class Hub {
|
|
|
1748
1882
|
);
|
|
1749
1883
|
}
|
|
1750
1884
|
|
|
1751
|
-
|
|
1885
|
+
/** `lang`: the UI language for the user notice, "ko" for the engine context (engine-facing text stays Korean). */
|
|
1886
|
+
private reasonLabel(reason: RotationReason, lang: Language): string {
|
|
1752
1887
|
const hours = Math.round((this.options.rotation?.maxAgeMs ?? ROTATE_MAX_AGE_MS) / 3_600_000);
|
|
1753
|
-
if (reason === "size") return
|
|
1754
|
-
if (reason === "compactions") return
|
|
1755
|
-
if (reason === "compaction_failed") return
|
|
1756
|
-
return reason === "manual" ?
|
|
1888
|
+
if (reason === "size") return textIn(lang, ROTATION_REASON.size, { mb: Math.round((this.options.rotation?.forceMaxBytes ?? FORCE_ROTATE_MAX_BYTES) / 1024 / 1024) });
|
|
1889
|
+
if (reason === "compactions") return textIn(lang, ROTATION_REASON.compactions, { count: this.options.rotation?.forceUpdateCompactions ?? FORCE_ROTATE_UPDATE_COMPACTIONS });
|
|
1890
|
+
if (reason === "compaction_failed") return textIn(lang, ROTATION_REASON.compaction_failed, { count: this.freshSessionCompactionFailures() });
|
|
1891
|
+
return reason === "manual" ? textIn(lang, ROTATION_REASON.manual) : textIn(lang, ROTATION_REASON.age, { hours });
|
|
1757
1892
|
}
|
|
1758
1893
|
|
|
1759
1894
|
/** Recent user/assistant lines from the gateway's own history, newest kept, within HANDOFF_MAX_CHARS. */
|
|
@@ -1774,7 +1909,7 @@ export class Hub {
|
|
|
1774
1909
|
lines.unshift(line);
|
|
1775
1910
|
}
|
|
1776
1911
|
return [
|
|
1777
|
-
`[게이트웨이 알림] 이 대화는 새 엔진 세션으로 교체됐습니다 (${this.reasonLabel(reason)}).`,
|
|
1912
|
+
`[게이트웨이 알림] 이 대화는 새 엔진 세션으로 교체됐습니다 (${this.reasonLabel(reason, "ko")}).`,
|
|
1778
1913
|
from ? `이전 세션의 전체 기록은 ${from} 에 남아 있습니다. 파일이 크니 필요할 때 끝부분이나 필요한 부분만 검색해서 보고, 전체를 읽지 마세요.` : "",
|
|
1779
1914
|
...this.handoffOpenItems(conv),
|
|
1780
1915
|
"아래는 직전 대화의 최근 부분입니다. 맥락으로만 참고하세요.",
|
|
@@ -1788,6 +1923,7 @@ export class Hub {
|
|
|
1788
1923
|
|
|
1789
1924
|
/** Sends input once. Native same-session compaction owns overflow recovery, never gateway replay. */
|
|
1790
1925
|
private async prompt(convId: string, body: string, images: string[] = [], messageSeq?: number, admitted = false, attachmentSnapshot?: ReturnType<Hub["attachments"]>, protectedPayload = false): Promise<void> {
|
|
1926
|
+
this.requireVisibleAgent(convId);
|
|
1791
1927
|
const conv = this.store.getConversation(convId);
|
|
1792
1928
|
if (!conv) return;
|
|
1793
1929
|
const rt = this.rt(convId);
|
|
@@ -1853,8 +1989,9 @@ export class Hub {
|
|
|
1853
1989
|
if (refused && input) this.emit(convId, "main.input.handled", { input_seq: input.seq, reason: "prompt_rejected" });
|
|
1854
1990
|
if (protectedPayload) throw new SecurityRuntimeError("dispatch_unknown");
|
|
1855
1991
|
if (retired) return;
|
|
1992
|
+
if (error instanceof AgentSessionError) return; // The definition failure was already persisted and broadcast.
|
|
1856
1993
|
const message = errorMessage(error);
|
|
1857
|
-
this.emit(convId, "error", refused ? { kind: "compaction-required", message: COMPACTION_REQUIRED_MESSAGE } : this.continuityError(message));
|
|
1994
|
+
this.emit(convId, "error", refused ? { kind: "compaction-required", message: say(COMPACTION_REQUIRED_MESSAGE) } : this.continuityError(message));
|
|
1858
1995
|
} finally {
|
|
1859
1996
|
releaseAdmission();
|
|
1860
1997
|
}
|
|
@@ -1864,7 +2001,7 @@ export class Hub {
|
|
|
1864
2001
|
if (!CONTEXT_OVERFLOW.test(message)) return { message };
|
|
1865
2002
|
return {
|
|
1866
2003
|
kind: "continuity-protected",
|
|
1867
|
-
message:
|
|
2004
|
+
message: say(HUB_TEXT.continuityProtected, { message }),
|
|
1868
2005
|
};
|
|
1869
2006
|
}
|
|
1870
2007
|
|
|
@@ -1890,20 +2027,91 @@ export class Hub {
|
|
|
1890
2027
|
const rt = this.rt(convId);
|
|
1891
2028
|
if (!rt.sessionId || !rt.running) return;
|
|
1892
2029
|
await this.requireOmo().command("abort", { sessionId: rt.sessionId });
|
|
1893
|
-
this.dialogs.cancelScope(this.conversationScope(convId),
|
|
2030
|
+
this.dialogs.cancelScope(this.conversationScope(convId), say(HUB_TEXT.userStopped));
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
/**
|
|
2034
|
+
* Improvement 230: `/compact` asks the engine to summarize this conversation's session now. The message stays in the
|
|
2035
|
+
* history and its input is closed before the RPC, so recovery never sends it. While an answer runs, a summary runs, a
|
|
2036
|
+
* retry waits or the session is being replaced it is refused with a notice and never queued. The engine's own
|
|
2037
|
+
* compaction_start/compaction_end events carry the usual start and terminal notices; this returns once the RPC is sent.
|
|
2038
|
+
*/
|
|
2039
|
+
private async manualCompact(convId: string, messageSeq: number, instructions: string, protectedPayload: boolean): Promise<void> {
|
|
2040
|
+
const conv = this.store.getConversation(convId);
|
|
2041
|
+
if (!conv) return;
|
|
2042
|
+
const rt = this.rt(convId);
|
|
2043
|
+
const refuse = (message: Text) => this.refuseCompact(convId, message, protectedPayload);
|
|
2044
|
+
const held = () => rt.rotating || this.closeUncertain(convId) ? COMPACT_REFUSED.session
|
|
2045
|
+
: this.manualCompactions.has(convId) ? COMPACT_REFUSED.compacting
|
|
2046
|
+
: rt.running || rt.promptAdmissions.size > 0 || rt.waking !== null || this.store.pendingMainUsers(convId).length > 0
|
|
2047
|
+
? COMPACT_REFUSED.answering : null;
|
|
2048
|
+
const early = this.omoStatus !== "ready" ? COMPACT_REFUSED.engine : held();
|
|
2049
|
+
if (early) return refuse(early);
|
|
2050
|
+
const generation = this.generation;
|
|
2051
|
+
let sessionId: string;
|
|
2052
|
+
let state: { isStreaming?: boolean; isCompacting?: boolean; isBashRunning?: boolean; pendingMessageCount?: number; retryAttempt?: number };
|
|
2053
|
+
try {
|
|
2054
|
+
({ sessionId } = await this.ensureSession(conv));
|
|
2055
|
+
state = await this.requireOmo().command("get_state", { sessionId });
|
|
2056
|
+
} catch (error) {
|
|
2057
|
+
console.error(`manual compaction state: ${errorMessage(error)}`);
|
|
2058
|
+
return refuse(COMPACT_REFUSED.engine);
|
|
2059
|
+
}
|
|
2060
|
+
if (this.omoStatus !== "ready") return refuse(COMPACT_REFUSED.engine);
|
|
2061
|
+
if (generation !== this.generation || rt.sessionId !== sessionId) return refuse(COMPACT_REFUSED.session);
|
|
2062
|
+
const busy = held() ?? (state.isCompacting ? COMPACT_REFUSED.compacting
|
|
2063
|
+
: (state.retryAttempt ?? 0) > 0 ? COMPACT_REFUSED.retrying
|
|
2064
|
+
: state.isStreaming || state.isBashRunning || (state.pendingMessageCount ?? 0) > 0 ? COMPACT_REFUSED.answering : null);
|
|
2065
|
+
if (busy) return refuse(busy);
|
|
2066
|
+
this.manualCompactions.add(convId);
|
|
2067
|
+
const input = this.emit(convId, "main.input", { origin: "user", input_ref: { message_seq: messageSeq },
|
|
2068
|
+
session_path: this.store.getConversation(convId)?.threadId, handoff: null });
|
|
2069
|
+
if (input) {
|
|
2070
|
+
this.store.ackMainInput(input);
|
|
2071
|
+
this.emit(convId, "main.input.handled", { input_seq: input.seq, reason: "compact" });
|
|
2072
|
+
}
|
|
2073
|
+
const before = this.store.lastSeq(convId, "session.compaction");
|
|
2074
|
+
void this.requireOmo().command("compact", { sessionId, ...(instructions ? { customInstructions: instructions } : {}) })
|
|
2075
|
+
.catch(async (error) => {
|
|
2076
|
+
if (generation !== this.generation || this.rt(convId).sessionId !== sessionId) return;
|
|
2077
|
+
// A summary that started reports its own terminal notice and failure log line (bug 223).
|
|
2078
|
+
const reported = this.store.eventsAfter(convId, before).some((event) => event.type === "session.compaction" &&
|
|
2079
|
+
(event.data as { status?: string }).status !== "running");
|
|
2080
|
+
if (reported) return;
|
|
2081
|
+
if (!this.providerErrorChecker) this.providerErrorChecker = (await import("./security-check.ts")).defaultChecker();
|
|
2082
|
+
console.error("manual_compaction_failed", JSON.stringify({ error: sanitizeProviderError(errorMessage(error), this.providerErrorChecker) ?? "unknown" }));
|
|
2083
|
+
this.emit(convId, "error", { kind: "compaction-failed", action: "retry", message: compactNotReported() });
|
|
2084
|
+
})
|
|
2085
|
+
.catch((error) => console.error(`manual compaction: ${errorMessage(error)}`))
|
|
2086
|
+
.finally(() => this.manualCompactions.delete(convId));
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
/** A security hold that released this `/compact` ends as failed (nothing was sent), not as an unconfirmed send. */
|
|
2090
|
+
private refuseCompact(convId: string, message: Text, protectedPayload: boolean): void {
|
|
2091
|
+
this.emit(convId, "notice", { kind: "compact-refused", level: "warning", message: say(message) });
|
|
2092
|
+
if (protectedPayload) throw new SecurityRuntimeError("dispatch_failed");
|
|
1894
2093
|
}
|
|
1895
2094
|
|
|
2095
|
+
|
|
1896
2096
|
/** omo's slash commands and skills for the conversation's session, trimmed to what the input menu shows. */
|
|
1897
2097
|
async commands(convId: string): Promise<SlashCommand[]> {
|
|
1898
2098
|
const conv = this.store.getConversation(convId);
|
|
1899
2099
|
if (!conv) throw new Error(`unknown_conversation:${convId}`);
|
|
1900
2100
|
const { sessionId } = await this.ensureSession(conv);
|
|
1901
2101
|
const { commands } = await this.requireOmo().command<{ commands: { name: string; description?: string; source: string }[] }>("get_commands", { sessionId });
|
|
1902
|
-
const
|
|
1903
|
-
const
|
|
2102
|
+
const visible = conv.agentId ? allowedAgentCommands(commands, this.agentSkills(conv.agentId)) : commands;
|
|
2103
|
+
const listed = visible.map((c) => ({ name: c.name, description: c.description ?? "", source: c.source }));
|
|
2104
|
+
const own = [...(this.options.versionReport ? [VERSION_COMMAND] : []), ...(this.options.usageReport ? [USAGE_COMMAND] : []), COMPACT_COMMAND];
|
|
1904
2105
|
return [...listed.filter((c) => !own.some((command) => command.name === c.name)), ...own];
|
|
1905
2106
|
}
|
|
1906
2107
|
|
|
2108
|
+
/** The agent's skills allowlist (D4.2, merged definition `skills`); the extension limits the session to the same set. */
|
|
2109
|
+
private agentSkills(agentId: string): ReadonlySet<string> {
|
|
2110
|
+
const files = this.options.agents ?? new AgentFileStore();
|
|
2111
|
+
const skills = mergeAgentDefinition(files.read(agentId), files.readDefaults()).fields.skills;
|
|
2112
|
+
return new Set(Array.isArray(skills) ? skills.filter((skill): skill is string => typeof skill === "string") : []);
|
|
2113
|
+
}
|
|
2114
|
+
|
|
1907
2115
|
answerAction(data: ActionAnswerData, conv?: string): boolean {
|
|
1908
2116
|
if (this.maintenanceId) throw new MaintenanceError("maintenance_held");
|
|
1909
2117
|
if (this.decisions) {
|
|
@@ -2031,9 +2239,9 @@ export class Hub {
|
|
|
2031
2239
|
? { engineTurn: { id: rt.statsTurnId, sessionId: rt.durableId, messageTimestamp: message.timestamp, messageIndex: rt.statsMessageIndex } } : {}),
|
|
2032
2240
|
});
|
|
2033
2241
|
if (message.stopReason === "aborted") {
|
|
2034
|
-
this.emit(conv, "notice", { message:
|
|
2242
|
+
this.emit(conv, "notice", { message: say(HUB_TEXT.stopped), level: "info" });
|
|
2035
2243
|
} else if (message.stopReason === "error" || message.errorMessage) {
|
|
2036
|
-
this.emit(conv, "error", this.continuityError(message.errorMessage ??
|
|
2244
|
+
this.emit(conv, "error", this.continuityError(message.errorMessage ?? say(HUB_TEXT.modelResponseError)));
|
|
2037
2245
|
}
|
|
2038
2246
|
rt.itemId = null;
|
|
2039
2247
|
rt.itemText = "";
|
|
@@ -2064,9 +2272,9 @@ export class Hub {
|
|
|
2064
2272
|
case "compaction_start": {
|
|
2065
2273
|
if (!blockingCompaction(event) || this.compactionSeen(conv, event, false)) return;
|
|
2066
2274
|
const requestId = typeof event.requestId === "string" ? event.requestId : undefined;
|
|
2067
|
-
this.emit(conv, "session.compaction", { status: "running", requestId });
|
|
2275
|
+
this.emit(conv, "session.compaction", { status: "running", requestId, ...(event.reason === "manual" ? { reason: "manual" } : {}) });
|
|
2068
2276
|
this.emit(conv, "notice", { kind: "compaction-started", level: "info", requestId,
|
|
2069
|
-
message:
|
|
2277
|
+
message: say(HUB_TEXT.compactionStarted) });
|
|
2070
2278
|
return;
|
|
2071
2279
|
}
|
|
2072
2280
|
case "compaction_end": {
|
|
@@ -2075,16 +2283,17 @@ export class Hub {
|
|
|
2075
2283
|
const outcome = compactionOutcome(event);
|
|
2076
2284
|
this.emit(conv, "session.compaction", {
|
|
2077
2285
|
...outcome, requestId, accepted: outcome.status === "completed",
|
|
2286
|
+
...(event.reason === "manual" ? { reason: "manual" } : {}),
|
|
2078
2287
|
});
|
|
2079
2288
|
switch (outcome.status) {
|
|
2080
2289
|
case "cancelled":
|
|
2081
2290
|
this.emit(conv, "notice", { kind: "compaction-cancelled", level: "warning", requestId,
|
|
2082
|
-
message:
|
|
2291
|
+
message: say(HUB_TEXT.compactionCancelled) });
|
|
2083
2292
|
break;
|
|
2084
2293
|
case "failed": {
|
|
2085
2294
|
const recovery = COMPACTION_RECOVERY[outcome.failureKind];
|
|
2086
2295
|
this.emit(conv, "error", { kind: "compaction-failed", requestId, failureKind: outcome.failureKind,
|
|
2087
|
-
action: recovery.action, message:
|
|
2296
|
+
action: recovery.action, message: say(COMPACTION_FAILED, { recovery: say(recovery.message) }) });
|
|
2088
2297
|
void this.logCompactionFailure(outcome.failureKind, event.failure)
|
|
2089
2298
|
.catch((error) => console.error(`compaction failure log: ${errorMessage(error)}`));
|
|
2090
2299
|
if (this.compactionFailuresInRow(conv) >= this.freshSessionCompactionFailures()) this.openFreshSession(conv);
|
|
@@ -2092,7 +2301,7 @@ export class Hub {
|
|
|
2092
2301
|
}
|
|
2093
2302
|
case "completed":
|
|
2094
2303
|
this.emit(conv, "notice", { kind: "compaction-completed", level: "info", requestId,
|
|
2095
|
-
message:
|
|
2304
|
+
message: say(HUB_TEXT.compactionCompleted) });
|
|
2096
2305
|
break;
|
|
2097
2306
|
}
|
|
2098
2307
|
return;
|
|
@@ -2103,7 +2312,7 @@ export class Hub {
|
|
|
2103
2312
|
rt.statsMessageIndex = 0;
|
|
2104
2313
|
this.publishActivity(conv);
|
|
2105
2314
|
this.unwatchConversation(conv);
|
|
2106
|
-
this.dialogs.cancelScope(owner.scope,
|
|
2315
|
+
this.dialogs.cancelScope(owner.scope, say(HUB_TEXT.turnEnded));
|
|
2107
2316
|
if (this.decisions) {
|
|
2108
2317
|
void this.reconcileDecisionMain(conv)
|
|
2109
2318
|
.then(() => this.decisions?.settled(owner.scope))
|
|
@@ -2180,7 +2389,7 @@ export class Hub {
|
|
|
2180
2389
|
legacyActionIds: [requestId],
|
|
2181
2390
|
method: method as DialogMethod,
|
|
2182
2391
|
permission,
|
|
2183
|
-
title: permission ?
|
|
2392
|
+
title: permission ? say(HUB_TEXT.permissionTitle, { tool: firstLine?.slice(PERMISSION_PREFIX.length).trim() ?? "" }) : (firstLine ?? ""),
|
|
2184
2393
|
detail: detailParts.filter(Boolean).join("\n\n"),
|
|
2185
2394
|
options: Array.isArray(event.options) ? (event.options as unknown[]).map(String) : [],
|
|
2186
2395
|
},
|
|
@@ -2292,12 +2501,12 @@ export class Hub {
|
|
|
2292
2501
|
this.questions.delete(id);
|
|
2293
2502
|
const historyActionId = this.legacyQuestionHistoryAliases.get(id) ?? id;
|
|
2294
2503
|
this.legacyQuestionHistoryAliases.delete(id);
|
|
2295
|
-
this.emit(owner.conv, "action.resolved", { actionId: historyActionId, answer: { kind: "cancel", reason:
|
|
2504
|
+
this.emit(owner.conv, "action.resolved", { actionId: historyActionId, answer: { kind: "cancel", reason: say(HUB_TEXT.endedWhileOff) }, by: "cancelled" });
|
|
2296
2505
|
}
|
|
2297
2506
|
for (const id of this.openQuestionCards(owner.conv, owner.scope)) {
|
|
2298
2507
|
if (live.has(id)) continue;
|
|
2299
2508
|
if (this.store.getDecisionByAlias(owner.scope, id)?.sourceRequestId.startsWith("legacy:")) continue;
|
|
2300
|
-
this.emit(owner.conv, "action.resolved", { actionId: id, answer: { kind: "cancel", reason:
|
|
2509
|
+
this.emit(owner.conv, "action.resolved", { actionId: id, answer: { kind: "cancel", reason: say(HUB_TEXT.endedWhileOff) }, by: "cancelled" });
|
|
2301
2510
|
}
|
|
2302
2511
|
}
|
|
2303
2512
|
|
|
@@ -2431,7 +2640,7 @@ export class Hub {
|
|
|
2431
2640
|
const recovered = this.emitMissedAnswers(conv.id, assistantTexts(currentEntries));
|
|
2432
2641
|
if (!this.hasUnfinishedTurn(conv.id)) return;
|
|
2433
2642
|
if (rt.running) {
|
|
2434
|
-
this.emit(conv.id, "notice", { kind: "reattached", level: "info", message:
|
|
2643
|
+
this.emit(conv.id, "notice", { kind: "reattached", level: "info", message: say(HUB_TEXT.reattached, { recovered: recoveredNote(recovered) }) });
|
|
2435
2644
|
return;
|
|
2436
2645
|
}
|
|
2437
2646
|
if (turn.source.origin === "work_update") {
|
|
@@ -2453,12 +2662,12 @@ export class Hub {
|
|
|
2453
2662
|
this.emit(conv.id, "notice", {
|
|
2454
2663
|
kind: "user-input-receipt-unknown", level: "warning",
|
|
2455
2664
|
message_seq: turn.source.input_ref.message_seq,
|
|
2456
|
-
message:
|
|
2665
|
+
message: say(HUB_TEXT.sendReceiptUnknown),
|
|
2457
2666
|
});
|
|
2458
2667
|
return;
|
|
2459
2668
|
}
|
|
2460
2669
|
if (turnFinished(currentEntries)) {
|
|
2461
|
-
this.emit(conv.id, "notice", { kind: "recovered", level: "info", message:
|
|
2670
|
+
this.emit(conv.id, "notice", { kind: "recovered", level: "info", message: say(HUB_TEXT.recovered, { recovered: recoveredNote(recovered) }) });
|
|
2462
2671
|
this.completeTurn(conv.id, "completed");
|
|
2463
2672
|
rt.wake?.release?.();
|
|
2464
2673
|
rt.wake = null;
|
|
@@ -2468,7 +2677,7 @@ export class Hub {
|
|
|
2468
2677
|
.eventsAfter(conv.id, this.store.lastSeq(conv.id, "message.user"))
|
|
2469
2678
|
.filter((e) => e.type === "notice" && (e.data as { kind?: string }).kind === "auto-continue").length;
|
|
2470
2679
|
if (tries >= AUTO_CONTINUE_LIMIT) {
|
|
2471
|
-
this.emit(conv.id, "notice", { kind: "interrupted", level: "warning", message:
|
|
2680
|
+
this.emit(conv.id, "notice", { kind: "interrupted", level: "warning", message: say(HUB_TEXT.interrupted, { tries }) });
|
|
2472
2681
|
this.completeTurn(conv.id, "interrupted");
|
|
2473
2682
|
rt.wake?.release?.();
|
|
2474
2683
|
rt.wake = null;
|
|
@@ -2478,7 +2687,7 @@ export class Hub {
|
|
|
2478
2687
|
|| currentEntries.some((e) => e.type === "message" && e.role === "user");
|
|
2479
2688
|
const lastUser = this.store.getEvent(turn.source.input_ref.message_seq)?.data as { text?: string; images?: string[] } | undefined;
|
|
2480
2689
|
const message = promptReached || !lastUser?.text ? CONTINUE_PROMPT : lastUser.text;
|
|
2481
|
-
this.emit(conv.id, "notice", { kind: "auto-continue", level: "warning", message:
|
|
2690
|
+
this.emit(conv.id, "notice", { kind: "auto-continue", level: "warning", message: say(HUB_TEXT.autoContinue, { attempt: tries + 1, limit: AUTO_CONTINUE_LIMIT, recovered: recoveredNote(recovered) }) });
|
|
2482
2691
|
await this.prompt(conv.id, message, promptReached ? [] : lastUser?.images ?? [], turn.source.input_ref.message_seq);
|
|
2483
2692
|
}
|
|
2484
2693
|
|
|
@@ -2486,6 +2695,14 @@ export class Hub {
|
|
|
2486
2695
|
return this.store.mainTurn(conv) !== null || this.store.pendingMainUsers(conv).length > 0;
|
|
2487
2696
|
}
|
|
2488
2697
|
|
|
2698
|
+
private requireVisibleAgent(convId: string): void {
|
|
2699
|
+
if (this.deletingAgents.has(convId)) throw new Error("agent_busy");
|
|
2700
|
+
const conv = this.store.getConversation(convId);
|
|
2701
|
+
if (conv?.agentId && !this.store.listConversations(false).some((visible) => visible.id === convId)) {
|
|
2702
|
+
throw new Error("agent_hidden");
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2489
2706
|
private completeTurn(conv: string, status: "completed" | "interrupted", pendingInputSeqs: number[] = []): void {
|
|
2490
2707
|
const turn = this.store.mainTurn(conv);
|
|
2491
2708
|
const source = this.store.mainConsumedSource(conv);
|
|
@@ -2653,6 +2870,29 @@ export class Hub {
|
|
|
2653
2870
|
private openSession(conv: ConversationInfo) {
|
|
2654
2871
|
const storedPath = conv.threadId?.startsWith("/") ? conv.threadId : null;
|
|
2655
2872
|
const sessionPath = storedPath ?? `${this.options.sessionDir.replace(/\/$/, "")}/zgw-${conv.id}.jsonl`;
|
|
2873
|
+
let agent: MergedAgentDefinition | undefined;
|
|
2874
|
+
let cwd = this.options.cwd;
|
|
2875
|
+
let model = this.savedModel();
|
|
2876
|
+
if (conv.agentId) {
|
|
2877
|
+
try {
|
|
2878
|
+
const files = this.options.agents ?? new AgentFileStore();
|
|
2879
|
+
agent = mergeAgentDefinition(files.read(conv.agentId), files.readDefaults());
|
|
2880
|
+
cwd = join(files.root, conv.agentId, "knowledge");
|
|
2881
|
+
const selected = agent.fields.model;
|
|
2882
|
+
if (selected !== undefined) {
|
|
2883
|
+
if (typeof selected !== "object" || selected === null ||
|
|
2884
|
+
!("provider" in selected) || typeof selected.provider !== "string" || !selected.provider.trim() ||
|
|
2885
|
+
!("modelId" in selected) || typeof selected.modelId !== "string" || !selected.modelId.trim()) {
|
|
2886
|
+
throw new AgentDefinitionError("model", "provider and modelId required");
|
|
2887
|
+
}
|
|
2888
|
+
model = { provider: selected.provider, modelId: selected.modelId };
|
|
2889
|
+
}
|
|
2890
|
+
} catch {
|
|
2891
|
+
// File/parser errors can contain private paths or definition text. Publish only the stable code.
|
|
2892
|
+
this.emit(conv.id, "error", { kind: "agent-definition", message: "agent_definition_invalid" });
|
|
2893
|
+
throw new AgentSessionError();
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2656
2896
|
const opened = this.requireOmo().command<{ sessionId: string; attached?: boolean; state?: {
|
|
2657
2897
|
isStreaming?: boolean; pendingQuestions?: ScopedQuestionFrame[];
|
|
2658
2898
|
clientInputIdentity?: boolean; atomicQueuedResume?: boolean;
|
|
@@ -2660,15 +2900,18 @@ export class Hub {
|
|
|
2660
2900
|
} }>("open_session", {
|
|
2661
2901
|
sessionPath,
|
|
2662
2902
|
// Native ignores creation defaults when the session file already exists.
|
|
2663
|
-
...(
|
|
2664
|
-
|
|
2903
|
+
...(model ?? {}),
|
|
2904
|
+
...(typeof agent?.fields.thinking === "string" ? { thinkingLevel: agent.fields.thinking } : {}),
|
|
2905
|
+
cwd,
|
|
2665
2906
|
permissionPreset: this.options.permissionPreset,
|
|
2666
2907
|
retain_on_disconnect: true,
|
|
2667
2908
|
kind: "interactive",
|
|
2668
2909
|
promptSurface: "app",
|
|
2669
|
-
context:
|
|
2910
|
+
context: conv.agentId
|
|
2911
|
+
? { zgw_conv: conv.id, zgw_role: "agent", zgw_agent: conv.agentId }
|
|
2912
|
+
: { zgw_conv: conv.id, zgw_role: "main" },
|
|
2670
2913
|
});
|
|
2671
|
-
return { sessionPath, opened };
|
|
2914
|
+
return { sessionPath, opened, agent };
|
|
2672
2915
|
}
|
|
2673
2916
|
|
|
2674
2917
|
private watchAsk(conv: string, toolCallId: string, args: unknown): void {
|
|
@@ -2713,6 +2956,7 @@ export class Hub {
|
|
|
2713
2956
|
|
|
2714
2957
|
private async ensureSession(conv: ConversationInfo, duringRotation = false): Promise<OpenResult> {
|
|
2715
2958
|
if (this.maintenanceId) throw new MaintenanceError("maintenance_held");
|
|
2959
|
+
if (!duringRotation) this.requireVisibleAgent(conv.id);
|
|
2716
2960
|
if (this.omoStatus !== "ready") throw new Error("omo_not_ready");
|
|
2717
2961
|
if (this.closeUncertain(conv.id)) throw new Error("session_close_unknown: source retained; verify native closure before retry");
|
|
2718
2962
|
const rt = this.rt(conv.id);
|
|
@@ -2720,9 +2964,9 @@ export class Hub {
|
|
|
2720
2964
|
if (rt.sessionId) return { sessionId: rt.sessionId, streaming: rt.running };
|
|
2721
2965
|
if (rt.opening) return rt.opening;
|
|
2722
2966
|
const generation = this.generation;
|
|
2723
|
-
const { sessionPath, opened } = this.openSession(conv);
|
|
2967
|
+
const { sessionPath, opened, agent } = this.openSession(conv);
|
|
2724
2968
|
const opening = opened
|
|
2725
|
-
.then((data) => {
|
|
2969
|
+
.then(async (data) => {
|
|
2726
2970
|
if (generation !== this.generation || this.omoStatus !== "ready") throw new Error("stale_open_session");
|
|
2727
2971
|
rt.sessionId = data.sessionId;
|
|
2728
2972
|
rt.durableId = typeof data.state?.sessionId === "string" ? data.state.sessionId : null;
|
|
@@ -2736,6 +2980,20 @@ export class Hub {
|
|
|
2736
2980
|
}
|
|
2737
2981
|
const owner = { conv: conv.id, scope: this.conversationScope(conv.id), sessionId: data.sessionId, sessionPath };
|
|
2738
2982
|
if (!this.bindScopedUiOwner(owner)) throw new Error("omo_owner_not_ready");
|
|
2983
|
+
if (agent && data.attached !== true) {
|
|
2984
|
+
// Notification policy is a session snapshot too; edits must not change a live session.
|
|
2985
|
+
this.emit(conv.id, "agent.applied", { sha: agent.sha, at: new Date(this.now()).toISOString(), notify: agent.fields.notify === true });
|
|
2986
|
+
let warnings: { code: string; message: string }[] = [];
|
|
2987
|
+
try {
|
|
2988
|
+
const result = await this.requireOmo().command<{ warnings?: { code: string; message: string }[] } | null>(
|
|
2989
|
+
"extension_request", { sessionId: data.sessionId, name: "zgw.agent_warnings" });
|
|
2990
|
+
if (Array.isArray(result?.warnings)) warnings = result.warnings;
|
|
2991
|
+
else console.error(`agent startup warnings failed (${conv.id}): invalid warnings response`);
|
|
2992
|
+
} catch (error) {
|
|
2993
|
+
console.error(`agent startup warnings failed (${conv.id}): ${errorMessage(error)}`);
|
|
2994
|
+
}
|
|
2995
|
+
for (const warning of warnings) this.emit(conv.id, "notice", { kind: "agent-warning", level: "warning", ...warning });
|
|
2996
|
+
}
|
|
2739
2997
|
this.syncScopedQuestions(owner, data.state?.pendingQuestions ?? []);
|
|
2740
2998
|
this.publishActivity(conv.id);
|
|
2741
2999
|
return { sessionId: data.sessionId, streaming: data.state?.isStreaming === true };
|
|
@@ -2812,9 +3070,9 @@ function unreachableQuestionText(args: unknown): string {
|
|
|
2812
3070
|
const questions = ((args as { questions?: unknown } | undefined)?.questions ?? []) as Partial<QuestionItem>[];
|
|
2813
3071
|
const lines = questions.map((q) => {
|
|
2814
3072
|
const options = (q.options ?? []).map((o) => o.label).join(" / ");
|
|
2815
|
-
return `- ${q.header ? `[${q.header}] ` : ""}${q.question ?? ""}${q.multiSelect ?
|
|
3073
|
+
return `- ${q.header ? `[${q.header}] ` : ""}${q.question ?? ""}${q.multiSelect ? say(HUB_TEXT.multiSelect) : ""}${options ? `: ${options}` : ""}`;
|
|
2816
3074
|
});
|
|
2817
|
-
return [
|
|
3075
|
+
return [say(HUB_TEXT.questionUnreachable), ...lines].join("\n");
|
|
2818
3076
|
}
|
|
2819
3077
|
|
|
2820
3078
|
/** 2026-09-26T15:43:01.123Z -> 20260926T154301Z */
|
|
@@ -2885,8 +3143,10 @@ function turnFinished(entries: readonly LedgerEntry[]): boolean {
|
|
|
2885
3143
|
return last?.role === "assistant" && last.stopReason === "stop";
|
|
2886
3144
|
}
|
|
2887
3145
|
|
|
2888
|
-
|
|
2889
|
-
|
|
3146
|
+
/** The sentence added to a recovery notice for answers the gateway filled in while it was away (exported for tests). */
|
|
3147
|
+
export function recoveredNote(count: number): string {
|
|
3148
|
+
if (count <= 0) return "";
|
|
3149
|
+
return count === 1 ? say(HUB_TEXT.recoveredOne) : say(HUB_TEXT.recoveredMany, { count });
|
|
2890
3150
|
}
|
|
2891
3151
|
|
|
2892
3152
|
function errorMessage(error: unknown): string {
|