zigmini 0.9.0-beta.2 → 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 +35 -5
- package/README.md +35 -5
- 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/model-control.ts +15 -3
- package/host/omo-bridge/task-control.ts +26 -16
- package/host/omo-check/patches/omo-5.1.48-compaction-recovery.patch +912 -0
- package/host/omo-check/patches/omo-5.1.48-gateway-port.manifest.json +12 -11
- 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 +525 -85
- package/src/main.ts +95 -9
- package/src/maintenance.ts +1 -1
- package/src/manual.ts +9 -2
- package/src/omo/client.ts +2 -0
- 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,13 +11,24 @@ 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
|
-
import type { MessageGuard } from "./security-check.ts";
|
|
17
|
+
import type { MessageGuard, SecurityChecker } from "./security-check.ts";
|
|
17
18
|
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;
|
|
@@ -52,7 +64,7 @@ export interface HubOptions {
|
|
|
52
64
|
/** Where attached images live (`dir`, as this process sees it) and the same folder's path on the Mac, for the agent. */
|
|
53
65
|
media?: { dir: string; hostDir?: string };
|
|
54
66
|
now?: () => number;
|
|
55
|
-
rotation?: { maxAgeMs?: number; idleMs?: number; forceMaxBytes?: number; forceUpdateCompactions?: number };
|
|
67
|
+
rotation?: { maxAgeMs?: number; idleMs?: number; forceMaxBytes?: number; forceUpdateCompactions?: number; freshSessionCompactionFailures?: number };
|
|
56
68
|
/** Bug 152: bounded native ownership reconciliation; timers are injectable for deterministic tests. */
|
|
57
69
|
closeRecovery?: {
|
|
58
70
|
intervalMs?: number;
|
|
@@ -74,22 +86,91 @@ 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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Bug 223: the longest provider error a compaction failure log line keeps, after its sensitive values are removed. */
|
|
122
|
+
export const COMPACTION_ERROR_MAX_CHARS = 512;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Bug 223: the provider's own error text with every value the repository security check finds (tokens, keys,
|
|
126
|
+
* credentials, home paths, e-mail, internal addresses, device ids) replaced by [redacted], on one line, at most
|
|
127
|
+
* COMPACTION_ERROR_MAX_CHARS. The cause wording stays. Values are found before the text is cut.
|
|
128
|
+
*/
|
|
129
|
+
export function sanitizeProviderError(text: unknown, checker: Pick<SecurityChecker, "runtimeOccurrences">): string | null {
|
|
130
|
+
if (typeof text !== "string") return null;
|
|
131
|
+
const withoutPrivateKeys = text.replace(/-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----[\s\S]*?(?:-----END (?:[A-Z0-9]+ )*PRIVATE KEY-----|$)/g, "[redacted]");
|
|
132
|
+
const flat = withoutPrivateKeys.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, " ").replace(/\s+/g, " ").trim();
|
|
133
|
+
if (!flat) return null;
|
|
134
|
+
const spans = checker.runtimeOccurrences([flat])
|
|
135
|
+
.map((found) => [found.start ?? 0, found.end ?? flat.length] as const).sort((a, b) => a[0] - b[0]);
|
|
136
|
+
let kept = "";
|
|
137
|
+
let at = 0;
|
|
138
|
+
for (const [start, end] of spans) {
|
|
139
|
+
if (end <= at) continue;
|
|
140
|
+
if (start >= at) kept += `${flat.slice(at, start)}[redacted]`;
|
|
141
|
+
at = end;
|
|
142
|
+
}
|
|
143
|
+
kept += flat.slice(at);
|
|
144
|
+
return kept.length > COMPACTION_ERROR_MAX_CHARS ? `${kept.slice(0, COMPACTION_ERROR_MAX_CHARS)}…` : kept;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Bug 223: the fields of one gateway log line per failed blocking compaction. A provider/model the engine did not list
|
|
149
|
+
* or a malformed number never reaches the log. The provider error is kept only through sanitizeProviderError;
|
|
150
|
+
* errorMessage and other free text are never read.
|
|
151
|
+
*/
|
|
152
|
+
export function compactionFailureLog(failureKind: CompactionFailureKind, failure: unknown, knownModels: ReadonlySet<string>,
|
|
153
|
+
sanitize: (text: unknown) => string | null = () => null): Record<string, unknown> {
|
|
154
|
+
const fields = typeof failure === "object" && failure !== null ? failure as Record<string, unknown> : {};
|
|
155
|
+
const provider = typeof fields.provider === "string" ? fields.provider : "";
|
|
156
|
+
const model = typeof fields.model === "string" ? fields.model : "";
|
|
157
|
+
const listed = knownModels.has(`${provider}/${model}`);
|
|
158
|
+
const tokens = fields.inputTokens;
|
|
159
|
+
const retryAt = fields.retryAt;
|
|
160
|
+
return {
|
|
161
|
+
failureKind,
|
|
162
|
+
provider: listed ? provider : "unknown",
|
|
163
|
+
model: listed ? model : "unknown",
|
|
164
|
+
error: sanitize(fields.error) ?? "unknown",
|
|
165
|
+
inputTokens: typeof tokens === "number" && Number.isSafeInteger(tokens) && tokens >= 0 ? tokens : "unknown",
|
|
166
|
+
inputTokensSource: fields.inputTokensSource === "estimate" || fields.inputTokensSource === "provider" ? fields.inputTokensSource : "unknown",
|
|
167
|
+
requestSent: typeof fields.requestSent === "boolean" ? fields.requestSent : "unknown",
|
|
168
|
+
...(typeof retryAt === "number" && Number.isSafeInteger(retryAt) && retryAt > 0 && retryAt < 8.64e15
|
|
169
|
+
? { retryAt: new Date(retryAt).toISOString() } : {}),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export type RotationReason = "manual" | "age" | "compaction_failed" | ForcedRotationReason;
|
|
93
174
|
/** Bug 72 proposal 4: limits that rotate a session even with works, updates, decisions or questions open. */
|
|
94
175
|
export type ForcedRotationReason = "size" | "compactions";
|
|
95
176
|
|
|
@@ -106,6 +187,10 @@ export const ROTATION_HELD_LOG_MS = 6 * 60 * 60 * 1000;
|
|
|
106
187
|
export const ROTATION_RETRY_MS = 30 * 60 * 1000;
|
|
107
188
|
/** Bug 72: completed turns that must carry an unconsumed user input before an idle native releases it from the turn. */
|
|
108
189
|
export const UNCONSUMED_INPUT_RELEASE_TURNS = 2;
|
|
190
|
+
/** Bug 224: the engine's exact refusal of a prompt before accepting it while compaction is unfinished (RequiredCompactionError). */
|
|
191
|
+
export const REQUIRED_COMPACTION_REFUSAL = "Context remains above the compaction threshold because compaction did not complete";
|
|
192
|
+
/** Bug 224 (user decision 2026-10-29): failed compactions in a row of one session that open a fresh session. */
|
|
193
|
+
export const FRESH_SESSION_COMPACTION_FAILURES = 2;
|
|
109
194
|
|
|
110
195
|
/** Bug 72: one condition that now holds a conversation's session rotation. */
|
|
111
196
|
export interface RotationBlocker {
|
|
@@ -147,6 +232,65 @@ function describeBlockers(blockers: readonly RotationBlocker[]): string {
|
|
|
147
232
|
const HANDOFF_MAX_CHARS = 8000;
|
|
148
233
|
const HANDOFF_LINE_CHARS = 600;
|
|
149
234
|
const CONTEXT_OVERFLOW = /prompt is too long|context overflow/i;
|
|
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>;
|
|
150
294
|
|
|
151
295
|
interface OpenResult {
|
|
152
296
|
sessionId: string;
|
|
@@ -176,6 +320,11 @@ export interface SlashCommand {
|
|
|
176
320
|
source: string;
|
|
177
321
|
}
|
|
178
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
|
+
|
|
179
328
|
interface WorkWake {
|
|
180
329
|
input: StoredEvent;
|
|
181
330
|
/** Releases a live receipt wait after reconciliation or connection loss. Absent after restart. */
|
|
@@ -189,6 +338,7 @@ interface ConvRuntime {
|
|
|
189
338
|
run: number;
|
|
190
339
|
rotating: boolean;
|
|
191
340
|
admitting: number;
|
|
341
|
+
promptAdmissions: Set<() => void>;
|
|
192
342
|
clientInputIdentity: boolean;
|
|
193
343
|
atomicQueuedResume: boolean;
|
|
194
344
|
/** `open_session` state.sessionId: the session file's own identity, which survives reconnects. */
|
|
@@ -322,6 +472,7 @@ export function modelOptions(models: OmoModel[]): ModelChoice[] {
|
|
|
322
472
|
|
|
323
473
|
export class Hub {
|
|
324
474
|
private readonly runtime = new Map<string, ConvRuntime>();
|
|
475
|
+
private readonly deletingAgents = new Set<string>();
|
|
325
476
|
private readonly sessionToConv = new Map<string, string>();
|
|
326
477
|
/** Open ask_user_question cards by scope-qualified gateway action ID. omo owns their lifetime. */
|
|
327
478
|
private readonly questions = new Map<string, QuestionCard>();
|
|
@@ -617,9 +768,10 @@ export class Hub {
|
|
|
617
768
|
}
|
|
618
769
|
|
|
619
770
|
snapshot(): Record<string, unknown> {
|
|
771
|
+
const conversations = this.store.listConversations(false);
|
|
620
772
|
return {
|
|
621
|
-
conversations
|
|
622
|
-
activity: Object.fromEntries(
|
|
773
|
+
conversations,
|
|
774
|
+
activity: Object.fromEntries(conversations.map((conv) => [conv.id, this.activity(conv.id)])),
|
|
623
775
|
approvals: [...this.dialogs.list(), ...this.questions.values(), ...this.releasedMainCards()],
|
|
624
776
|
sessionTransitions: [...this.runtime]
|
|
625
777
|
.filter(([, runtime]) => runtime.rotating)
|
|
@@ -659,6 +811,10 @@ export class Hub {
|
|
|
659
811
|
|
|
660
812
|
/** Whether the last model answer named a held switch; the next settled main turn re-reads it. */
|
|
661
813
|
private modelPending = false;
|
|
814
|
+
private readonly knownModels = new Set<string>();
|
|
815
|
+
private providerErrorChecker: SecurityChecker | null = null;
|
|
816
|
+
/** Improvement 230: conversations whose `/compact` RPC has not answered yet. */
|
|
817
|
+
private readonly manualCompactions = new Set<string>();
|
|
662
818
|
|
|
663
819
|
/** The models omo can use right now (its logged-in providers) and the one a conversation's session is on. */
|
|
664
820
|
async models(convId = "main"): Promise<ModelInfo> {
|
|
@@ -671,6 +827,7 @@ export class Hub {
|
|
|
671
827
|
omo.command<{ model?: OmoModel | null; thinkingLevel?: string; pendingModelSwitch?: { provider: string; id: string } | null }>("get_state", { sessionId }),
|
|
672
828
|
]);
|
|
673
829
|
const options = modelOptions(available.models ?? []);
|
|
830
|
+
for (const option of options) this.knownModels.add(`${option.provider}/${option.id}`);
|
|
674
831
|
const held = state.pendingModelSwitch;
|
|
675
832
|
const pending = held
|
|
676
833
|
? options.find((o) => o.provider === held.provider && o.id === held.id) ?? { provider: held.provider, id: held.id, name: held.id }
|
|
@@ -725,13 +882,43 @@ export class Hub {
|
|
|
725
882
|
}
|
|
726
883
|
}
|
|
727
884
|
|
|
728
|
-
createConversation(title: string): ConversationInfo {
|
|
729
|
-
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);
|
|
730
887
|
this.emit("*", "conversation.created", conv);
|
|
731
888
|
this.publishActivity(conv.id);
|
|
732
889
|
return conv;
|
|
733
890
|
}
|
|
734
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
|
+
|
|
735
922
|
onOmoStatus(status: OmoStatus, detail?: string): Promise<number> {
|
|
736
923
|
const activityRecovery = this.beginActivityRecovery();
|
|
737
924
|
this.omoStatus = status;
|
|
@@ -759,7 +946,7 @@ export class Hub {
|
|
|
759
946
|
rt.wake = null;
|
|
760
947
|
rt.notified.clear();
|
|
761
948
|
const scope = this.conversationScope(conv);
|
|
762
|
-
this.dialogs.cancelScope(scope,
|
|
949
|
+
this.dialogs.cancelScope(scope, say(HUB_TEXT.engineDisconnected));
|
|
763
950
|
for (const [id, card] of this.questions) {
|
|
764
951
|
if (card.scope !== scope) continue;
|
|
765
952
|
this.questions.delete(id);
|
|
@@ -822,7 +1009,7 @@ export class Hub {
|
|
|
822
1009
|
this.activityRecoveryFailedGeneration = activityRecovery;
|
|
823
1010
|
this.publishAllActivities();
|
|
824
1011
|
}
|
|
825
|
-
this.emit(conv.id, "error", { message:
|
|
1012
|
+
if (!(error instanceof AgentSessionError)) this.emit(conv.id, "error", { message: say(HUB_TEXT.recoveryFailed, { error: errorMessage(error) }) });
|
|
826
1013
|
}
|
|
827
1014
|
}
|
|
828
1015
|
}
|
|
@@ -830,6 +1017,7 @@ export class Hub {
|
|
|
830
1017
|
/** `images` are names of files already uploaded to the media folder; unknown names are dropped. */
|
|
831
1018
|
async sendText(convId: string, text: string, src: Source, trust: Trust, images: string[] = [], referenceIds?: unknown, checked = false, submissionId?: string): Promise<void> {
|
|
832
1019
|
if (this.maintenanceId) throw new MaintenanceError("maintenance_held");
|
|
1020
|
+
this.requireVisibleAgent(convId);
|
|
833
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");
|
|
834
1022
|
const context = { ...(submissionId ? { key: JSON.stringify(["message", convId, submissionId]), submissionId } : {}),
|
|
835
1023
|
snapshot: structuredClone({ text, src, trust, images, referenceIds }) };
|
|
@@ -847,6 +1035,7 @@ export class Hub {
|
|
|
847
1035
|
this.emit(convId, "notice", { kind: "usage", level: "info", message: await this.options.usageReport() });
|
|
848
1036
|
return;
|
|
849
1037
|
}
|
|
1038
|
+
const compact = images.length === 0 && references.length === 0 ? compactInstructions(body) : null;
|
|
850
1039
|
const dir = this.options.media?.dir;
|
|
851
1040
|
const attached = dir ? [...new Set(images)].filter((n) => mediaFile(dir, n)).slice(0, MAX_ATTACHMENTS) : [];
|
|
852
1041
|
const prompt = referencedInput(body, attached, references);
|
|
@@ -857,13 +1046,17 @@ export class Hub {
|
|
|
857
1046
|
if (!body && attached.length === 0 && references.length === 0) return;
|
|
858
1047
|
const attachmentSnapshot = this.attachments(prompt.images);
|
|
859
1048
|
const send = async (receipt?: (ref: SecurityReceipt) => void) => {
|
|
1049
|
+
this.requireVisibleAgent(convId);
|
|
860
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);
|
|
861
1053
|
const input = this.store.acceptMainUser(convId, {
|
|
862
1054
|
text: body, src: source, trust, ...(attached.length ? { images: attached } : {}),
|
|
863
1055
|
...(references.length ? { references } : {}),
|
|
864
|
-
}, receipt);
|
|
1056
|
+
}, receipt, compact !== null);
|
|
865
1057
|
this.publishStored(input);
|
|
866
1058
|
this.decisions?.refresh();
|
|
1059
|
+
if (compact !== null) return this.manualCompact(convId, input.seq, compact, receipt !== undefined);
|
|
867
1060
|
const rotationDone = this.rt(convId).rotationDone;
|
|
868
1061
|
if (rotationDone) await rotationDone;
|
|
869
1062
|
if (this.closeUncertain(convId)) {
|
|
@@ -873,7 +1066,7 @@ export class Hub {
|
|
|
873
1066
|
}
|
|
874
1067
|
this.scheduleCloseRecovery(convId);
|
|
875
1068
|
this.emit(convId, "notice", { kind: "session-close-recovery", level: "info",
|
|
876
|
-
message:
|
|
1069
|
+
message: say(HUB_TEXT.closeChecking) });
|
|
877
1070
|
return;
|
|
878
1071
|
}
|
|
879
1072
|
await this.prompt(convId, prompt.text, prompt.images, input.seq, true, attachmentSnapshot, receipt !== undefined);
|
|
@@ -930,7 +1123,7 @@ export class Hub {
|
|
|
930
1123
|
const generation = this.generation;
|
|
931
1124
|
const flight = this.injectWorkUpdates(convId, generation).catch((error) => {
|
|
932
1125
|
if (this.omoStatus === "ready" && generation === this.generation) {
|
|
933
|
-
this.emit(convId, "error", { message:
|
|
1126
|
+
this.emit(convId, "error", { message: say(HUB_TEXT.workNotifyFailed, { error: errorMessage(error) }) });
|
|
934
1127
|
}
|
|
935
1128
|
}).finally(() => {
|
|
936
1129
|
if (rt.waking !== flight) return;
|
|
@@ -992,7 +1185,7 @@ export class Hub {
|
|
|
992
1185
|
if (!acknowledged && generation === this.generation && this.omoStatus === "ready" && rt.wake === wake) {
|
|
993
1186
|
this.emit(convId, "notice", {
|
|
994
1187
|
kind: "work-input-receipt-unknown", level: "warning",
|
|
995
|
-
input_seq: input.seq, message:
|
|
1188
|
+
input_seq: input.seq, message: say(HUB_TEXT.workReceiptUnknown),
|
|
996
1189
|
});
|
|
997
1190
|
}
|
|
998
1191
|
}
|
|
@@ -1037,7 +1230,7 @@ export class Hub {
|
|
|
1037
1230
|
}
|
|
1038
1231
|
} catch (error) {
|
|
1039
1232
|
if (generation === this.generation && this.omoStatus === "ready") {
|
|
1040
|
-
this.emit(conv, "error", { message:
|
|
1233
|
+
this.emit(conv, "error", { message: say(HUB_TEXT.workReconcileFailed, { error: errorMessage(error) }) });
|
|
1041
1234
|
}
|
|
1042
1235
|
}
|
|
1043
1236
|
if (generation !== this.generation || this.omoStatus !== "ready" || rt.sessionId !== sessionId || rt.run !== run) return;
|
|
@@ -1057,7 +1250,7 @@ export class Hub {
|
|
|
1057
1250
|
await this.reconcileWorkInput(pendingWake.input, sessionId, generation, true);
|
|
1058
1251
|
} catch (error) {
|
|
1059
1252
|
if (generation === this.generation && this.omoStatus === "ready") {
|
|
1060
|
-
this.emit(conv, "error", { message:
|
|
1253
|
+
this.emit(conv, "error", { message: say(HUB_TEXT.workReconcileFailed, { error: errorMessage(error) }) });
|
|
1061
1254
|
}
|
|
1062
1255
|
}
|
|
1063
1256
|
if (generation !== this.generation || this.omoStatus !== "ready" || rt.sessionId !== sessionId || rt.run !== run) return;
|
|
@@ -1118,8 +1311,7 @@ export class Hub {
|
|
|
1118
1311
|
&& (event.data as { input_seq?: number }).input_seq === input.seq).at(-1);
|
|
1119
1312
|
if ((previous?.data as { kind?: string } | undefined)?.kind !== kind) this.emit(conv, "notice", {
|
|
1120
1313
|
kind, input_seq: input.seq, level: "warning",
|
|
1121
|
-
message: queued ?
|
|
1122
|
-
: "메시지의 실행 소비를 확인하지 못했습니다. 기록을 보존하며 자동 재전송하지 않습니다.",
|
|
1314
|
+
message: say(queued ? HUB_TEXT.inputQueued : HUB_TEXT.inputConsumptionUnknown),
|
|
1123
1315
|
});
|
|
1124
1316
|
if (queued && data.native_identity && rt.atomicQueuedResume && state &&
|
|
1125
1317
|
state.isStreaming === false && typeof state.queueEpoch === "string") {
|
|
@@ -1130,7 +1322,7 @@ export class Hub {
|
|
|
1130
1322
|
sessionId, clientInputId: data.input_id, queueEpoch: state.queueEpoch,
|
|
1131
1323
|
}).catch((error) => {
|
|
1132
1324
|
if (generation === this.generation && this.omoStatus === "ready" && rt.sessionId === sessionId) {
|
|
1133
|
-
this.emit(conv, "error", { message:
|
|
1325
|
+
this.emit(conv, "error", { message: say(HUB_TEXT.queuedResumeFailed, { error: errorMessage(error) }) });
|
|
1134
1326
|
}
|
|
1135
1327
|
});
|
|
1136
1328
|
}
|
|
@@ -1154,7 +1346,7 @@ export class Hub {
|
|
|
1154
1346
|
}
|
|
1155
1347
|
} catch (error) {
|
|
1156
1348
|
if (generation === this.generation && this.omoStatus === "ready" && rt.sessionId === sessionId) {
|
|
1157
|
-
this.emit(conv, "error", { message:
|
|
1349
|
+
this.emit(conv, "error", { message: say(HUB_TEXT.userInputReconcileFailed, { error: errorMessage(error) }) });
|
|
1158
1350
|
}
|
|
1159
1351
|
} finally {
|
|
1160
1352
|
clearTimeout(timer);
|
|
@@ -1181,11 +1373,13 @@ export class Hub {
|
|
|
1181
1373
|
*/
|
|
1182
1374
|
async rotateSession(convId: string, reason: RotationReason): Promise<ConversationInfo> {
|
|
1183
1375
|
if (this.maintenanceId) throw new MaintenanceError("maintenance_held");
|
|
1376
|
+
this.requireVisibleAgent(convId);
|
|
1184
1377
|
const conv = this.store.getConversation(convId);
|
|
1185
1378
|
if (!conv) throw new Error(`unknown_conversation:${convId}`);
|
|
1186
1379
|
const rt = this.rt(convId);
|
|
1187
1380
|
const forced = reason === "size" || reason === "compactions";
|
|
1188
|
-
|
|
1381
|
+
const residue = this.residualTurn(convId, reason);
|
|
1382
|
+
if (rt.rotating || (this.rotationHeldFor(convId, forced) && residue === null)) throw new Error("turn_running");
|
|
1189
1383
|
if (this.omoStatus !== "ready" || this.closeUncertain(convId)) throw new Error("session_ownership_unknown");
|
|
1190
1384
|
const generation = this.generation;
|
|
1191
1385
|
const from = conv.threadId;
|
|
@@ -1195,7 +1389,7 @@ export class Hub {
|
|
|
1195
1389
|
rt.rotating = true;
|
|
1196
1390
|
rt.rotationDone = latch.promise;
|
|
1197
1391
|
rt.finishRotation = latch.resolve;
|
|
1198
|
-
this.emit(convId, "session.transition", { active: true, label:
|
|
1392
|
+
this.emit(convId, "session.transition", { active: true, label: say(HUB_TEXT.sessionTransition) }, false);
|
|
1199
1393
|
try {
|
|
1200
1394
|
const { sessionId } = await this.ensureSession(conv, true);
|
|
1201
1395
|
const readiness = await this.requireOmo().command<{
|
|
@@ -1243,7 +1437,7 @@ export class Hub {
|
|
|
1243
1437
|
rt.sessionId !== sessionId ||
|
|
1244
1438
|
from !== source ||
|
|
1245
1439
|
this.store.lastSeq(convId, "message.user") !== admittedUser ||
|
|
1246
|
-
this.rotationHeldFor(convId, forced)
|
|
1440
|
+
(this.rotationHeldFor(convId, forced) && (residue === null || this.residualTurn(convId, reason) !== residue))
|
|
1247
1441
|
) {
|
|
1248
1442
|
throw new Error("turn_running");
|
|
1249
1443
|
}
|
|
@@ -1288,6 +1482,95 @@ export class Hub {
|
|
|
1288
1482
|
}
|
|
1289
1483
|
}
|
|
1290
1484
|
|
|
1485
|
+
/**
|
|
1486
|
+
* Bug 224: a manual rotation may retire a session whose main turn left only residue: inputs nothing runs any more and
|
|
1487
|
+
* a work wake whose send already ended. Returns that residue's identity, or null when anything else holds. rotateSession
|
|
1488
|
+
* still proves the engine idle (no streaming, compaction, retry, queue or question) before it closes the session.
|
|
1489
|
+
*/
|
|
1490
|
+
private residualTurn(conv: string, reason: RotationReason): string | null {
|
|
1491
|
+
if (reason !== "manual") return null;
|
|
1492
|
+
const rt = this.rt(conv);
|
|
1493
|
+
const holds = this.rotationHoldsFor(conv, false);
|
|
1494
|
+
if (holds.length === 0 || this.store.pendingMainUsers(conv).length > 0) return null;
|
|
1495
|
+
const residual = holds.every((hold) => hold.kind === "main_turn" &&
|
|
1496
|
+
(hold.state === "unfinished_turn" || (hold.state === "waking" && rt.waking === null)));
|
|
1497
|
+
if (!residual) return null;
|
|
1498
|
+
const turn = this.store.mainTurn(conv);
|
|
1499
|
+
return JSON.stringify({ turn: turn?.seq ?? null, inputs: turn?.inputs.map((input) => input.seq) ?? [], wake: rt.wake?.input.seq ?? null });
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
/**
|
|
1503
|
+
* Bug 224 (user decision 2026-10-29): FRESH_SESSION_COMPACTION_FAILURES failed compactions in a row open a fresh session
|
|
1504
|
+
* without asking the old one for anything. The old session is detached first, so its late events never reach this
|
|
1505
|
+
* conversation; stopping and closing it are best effort. Its file stays; the handoff names it and carries only the
|
|
1506
|
+
* recent exchange. Works, decisions and never-sent user input carry over as in any rotation; nothing is replayed.
|
|
1507
|
+
*/
|
|
1508
|
+
private openFreshSession(convId: string): void {
|
|
1509
|
+
if (this.maintenanceId || this.omoStatus !== "ready") return;
|
|
1510
|
+
const conv = this.store.getConversation(convId);
|
|
1511
|
+
const rt = this.rt(convId);
|
|
1512
|
+
if (!conv?.threadId || rt.rotating) return;
|
|
1513
|
+
const from = conv.threadId;
|
|
1514
|
+
const sessionId = rt.sessionId;
|
|
1515
|
+
// Synchronous from the check above to the new thread, so no prompt or rotation interleaves; no latch is needed.
|
|
1516
|
+
this.emit(convId, "session.transition", { active: true, label: say(HUB_TEXT.sessionTransition) }, false);
|
|
1517
|
+
try {
|
|
1518
|
+
if (sessionId) this.retireScopedQuestions(convId, sessionId);
|
|
1519
|
+
rt.running = false;
|
|
1520
|
+
rt.durableId = null;
|
|
1521
|
+
rt.run++;
|
|
1522
|
+
this.finishRetirement(convId, {
|
|
1523
|
+
sessionId: sessionId ?? "", sessionPath: from, from, reason: "compaction_failed",
|
|
1524
|
+
handoff: this.buildHandoff(convId, "compaction_failed", from),
|
|
1525
|
+
});
|
|
1526
|
+
this.publishActivity(convId);
|
|
1527
|
+
} finally {
|
|
1528
|
+
this.emit(convId, "session.transition", { active: false, label: null }, false);
|
|
1529
|
+
}
|
|
1530
|
+
if (sessionId) void this.stopAbandonedSession(sessionId);
|
|
1531
|
+
if (this.store.pendingMainUsers(convId).length > 0) void this.recover();
|
|
1532
|
+
void this.notifyWorkUpdates(convId);
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
/** A detached session gets one abort and one close; it no longer routes to any conversation, so failures only log. */
|
|
1536
|
+
private async stopAbandonedSession(sessionId: string): Promise<void> {
|
|
1537
|
+
for (const command of ["abort", "close_session"] as const) {
|
|
1538
|
+
try {
|
|
1539
|
+
await this.requireOmo().command(command, { sessionId });
|
|
1540
|
+
} catch (error) {
|
|
1541
|
+
console.error(`abandoned session ${command} failed (${sessionId}): ${errorMessage(error)}`);
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
/** Bug 223: log with cached model identities; an unhealthy engine must never hold its own failure diagnostic. */
|
|
1547
|
+
private async logCompactionFailure(failureKind: CompactionFailureKind, failure: unknown): Promise<void> {
|
|
1548
|
+
// Loaded on first use: the security rules ship beside src/, and only a failure needs them.
|
|
1549
|
+
if (!this.providerErrorChecker) this.providerErrorChecker = (await import("./security-check.ts")).defaultChecker();
|
|
1550
|
+
const checker = this.providerErrorChecker;
|
|
1551
|
+
console.error("compaction_failed", JSON.stringify(compactionFailureLog(failureKind, failure, this.knownModels,
|
|
1552
|
+
(text) => sanitizeProviderError(text, checker))));
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
private freshSessionCompactionFailures(): number {
|
|
1556
|
+
return this.options.rotation?.freshSessionCompactionFailures ?? FRESH_SESSION_COMPACTION_FAILURES;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
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
|
+
*/
|
|
1563
|
+
private compactionFailuresInRow(conv: string): number {
|
|
1564
|
+
const failed = new Set<string>();
|
|
1565
|
+
for (const event of this.store.eventsAfter(conv, this.store.lastSeq(conv, "session.rotated")).reverse()) {
|
|
1566
|
+
if (event.type !== "session.compaction") continue;
|
|
1567
|
+
const data = event.data as { status?: string; requestId?: unknown; reason?: unknown };
|
|
1568
|
+
if (data.status === "completed") break;
|
|
1569
|
+
if (data.status === "failed" && data.reason !== "manual" && typeof data.requestId === "string") failed.add(data.requestId);
|
|
1570
|
+
}
|
|
1571
|
+
return failed.size;
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1291
1574
|
private closeUncertain(conv: string): boolean {
|
|
1292
1575
|
return this.store.lastSeq(conv, "session.close.unknown") >
|
|
1293
1576
|
Math.max(this.store.lastSeq(conv, "session.rotated"), this.store.lastSeq(conv, "session.close.resolved"));
|
|
@@ -1304,9 +1587,16 @@ export class Hub {
|
|
|
1304
1587
|
|
|
1305
1588
|
private finishRetirement(convId: string, retirement: SessionRetirement): void {
|
|
1306
1589
|
const rt = this.rt(convId);
|
|
1307
|
-
const
|
|
1308
|
-
|
|
1309
|
-
|
|
1590
|
+
for (const release of rt.promptAdmissions) release();
|
|
1591
|
+
if (retirement.sessionId) {
|
|
1592
|
+
const owner = this.ownerForConversation(convId, retirement.sessionId);
|
|
1593
|
+
this.sessionToConv.delete(retirement.sessionId);
|
|
1594
|
+
if (owner) this.releaseScopedUiOwner(owner);
|
|
1595
|
+
}
|
|
1596
|
+
// Bug 224: nothing the retired session left unfinished can run any more; close it so recovery never replays it.
|
|
1597
|
+
if (this.store.mainTurn(convId)) this.completeTurn(convId, "interrupted");
|
|
1598
|
+
rt.wake?.release?.();
|
|
1599
|
+
rt.wake = null;
|
|
1310
1600
|
rt.sessionId = null;
|
|
1311
1601
|
rt.itemId = null;
|
|
1312
1602
|
rt.itemText = "";
|
|
@@ -1318,7 +1608,8 @@ export class Hub {
|
|
|
1318
1608
|
this.emit(convId, "conversation.thread", { threadId: to });
|
|
1319
1609
|
this.emit(convId, "session.rotated", { reason: retirement.reason, from: retirement.from, to });
|
|
1320
1610
|
this.emit(convId, "notice", { kind: "session-rotated", level: "info",
|
|
1321
|
-
message:
|
|
1611
|
+
message: say(HUB_TEXT.sessionRotated, { reason: this.reasonLabel(retirement.reason, serverLanguage()),
|
|
1612
|
+
extra: retirement.reason === "compaction_failed" ? say(HUB_TEXT.rotatedAfterFailure) : "" }) });
|
|
1322
1613
|
}
|
|
1323
1614
|
|
|
1324
1615
|
private closeTimer(callback: () => void, ms: number): ReturnType<typeof setTimeout> {
|
|
@@ -1388,14 +1679,14 @@ export class Hub {
|
|
|
1388
1679
|
this.emit(convId, "session.close.resolved", { sessionId: data.sessionId, sessionPath: data.sessionPath,
|
|
1389
1680
|
outcome: present ? "retained" : "superseded", attempts: recovery.attempts });
|
|
1390
1681
|
this.emit(convId, "notice", { kind: "session-close-resolved", level: "info",
|
|
1391
|
-
message:
|
|
1682
|
+
message: say(HUB_TEXT.closeResolved) });
|
|
1392
1683
|
}
|
|
1393
1684
|
this.closeRecoveries.delete(convId);
|
|
1394
1685
|
} catch (error) {
|
|
1395
1686
|
if (generation !== this.generation) return;
|
|
1396
1687
|
if (recovery.attempts >= (this.options.closeRecovery?.maxAttempts ?? 3)) {
|
|
1397
1688
|
recovery.exhausted = true;
|
|
1398
|
-
this.emit(convId, "error", { message:
|
|
1689
|
+
this.emit(convId, "error", { message: say(HUB_TEXT.closeRecoveryExhausted, { error: errorMessage(error) }) });
|
|
1399
1690
|
}
|
|
1400
1691
|
} finally {
|
|
1401
1692
|
if (timeout !== undefined) this.clearCloseTimer(timeout);
|
|
@@ -1558,7 +1849,7 @@ export class Hub {
|
|
|
1558
1849
|
this.legacyQuestionHistoryAliases.delete(id);
|
|
1559
1850
|
this.emit(conv, "action.resolved", {
|
|
1560
1851
|
actionId: historyActionId,
|
|
1561
|
-
answer: { kind: "cancel", reason:
|
|
1852
|
+
answer: { kind: "cancel", reason: say(HUB_TEXT.rotationQuestionClosed) },
|
|
1562
1853
|
by: "cancelled",
|
|
1563
1854
|
});
|
|
1564
1855
|
}
|
|
@@ -1591,11 +1882,13 @@ export class Hub {
|
|
|
1591
1882
|
);
|
|
1592
1883
|
}
|
|
1593
1884
|
|
|
1594
|
-
|
|
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 {
|
|
1595
1887
|
const hours = Math.round((this.options.rotation?.maxAgeMs ?? ROTATE_MAX_AGE_MS) / 3_600_000);
|
|
1596
|
-
if (reason === "size") return
|
|
1597
|
-
if (reason === "compactions") return
|
|
1598
|
-
|
|
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 });
|
|
1599
1892
|
}
|
|
1600
1893
|
|
|
1601
1894
|
/** Recent user/assistant lines from the gateway's own history, newest kept, within HANDOFF_MAX_CHARS. */
|
|
@@ -1616,8 +1909,8 @@ export class Hub {
|
|
|
1616
1909
|
lines.unshift(line);
|
|
1617
1910
|
}
|
|
1618
1911
|
return [
|
|
1619
|
-
`[게이트웨이 알림] 이 대화는 새 엔진 세션으로 교체됐습니다 (${this.reasonLabel(reason)}).`,
|
|
1620
|
-
from ? `이전 세션의 전체 기록은 ${from} 에 남아 있습니다. 파일이 크니 필요할 때 필요한 부분만 검색해서
|
|
1912
|
+
`[게이트웨이 알림] 이 대화는 새 엔진 세션으로 교체됐습니다 (${this.reasonLabel(reason, "ko")}).`,
|
|
1913
|
+
from ? `이전 세션의 전체 기록은 ${from} 에 남아 있습니다. 파일이 크니 필요할 때 끝부분이나 필요한 부분만 검색해서 보고, 전체를 읽지 마세요.` : "",
|
|
1621
1914
|
...this.handoffOpenItems(conv),
|
|
1622
1915
|
"아래는 직전 대화의 최근 부분입니다. 맥락으로만 참고하세요.",
|
|
1623
1916
|
"---",
|
|
@@ -1630,19 +1923,29 @@ export class Hub {
|
|
|
1630
1923
|
|
|
1631
1924
|
/** Sends input once. Native same-session compaction owns overflow recovery, never gateway replay. */
|
|
1632
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);
|
|
1633
1927
|
const conv = this.store.getConversation(convId);
|
|
1634
1928
|
if (!conv) return;
|
|
1635
1929
|
const rt = this.rt(convId);
|
|
1636
1930
|
if (rt.rotating) return; // Accepted user intent remains definitely unsent until rotation finishes.
|
|
1637
1931
|
const generation = this.generation;
|
|
1932
|
+
let input: StoredEvent | null = null;
|
|
1933
|
+
let nativeCommand = false;
|
|
1934
|
+
let sessionId: string | null = null;
|
|
1935
|
+
const releaseAdmission = () => {
|
|
1936
|
+
if (rt.promptAdmissions.delete(releaseAdmission)) rt.admitting -= 1;
|
|
1937
|
+
};
|
|
1938
|
+
rt.promptAdmissions.add(releaseAdmission);
|
|
1939
|
+
// Hold user admission through RPC settlement, including the idle-before-agent_start interval.
|
|
1940
|
+
rt.admitting += 1;
|
|
1638
1941
|
try {
|
|
1639
|
-
|
|
1942
|
+
({ sessionId } = await this.ensureSession(conv));
|
|
1640
1943
|
if (generation !== this.generation || this.omoStatus !== "ready") return;
|
|
1641
1944
|
const reference = messageSeq ?? this.store.lastSeq(convId, "message.user");
|
|
1642
1945
|
// sendText and reconnect recovery can share admission; only one may cross the write boundary.
|
|
1643
1946
|
if (admitted && this.store.mainUserDispatched(convId, reference)) return;
|
|
1644
1947
|
const commandName = body.slice(1).split(" ", 1)[0];
|
|
1645
|
-
|
|
1948
|
+
nativeCommand = (body === "." && images.length === 0) ||
|
|
1646
1949
|
(body.startsWith("/") && (await this.commands(convId)).some((command) => command.name === commandName));
|
|
1647
1950
|
if (generation !== this.generation || this.omoStatus !== "ready" || rt.sessionId !== sessionId) return;
|
|
1648
1951
|
if (admitted && this.store.mainUserDispatched(convId, reference)) return;
|
|
@@ -1660,7 +1963,7 @@ export class Hub {
|
|
|
1660
1963
|
...(rt.clientInputIdentity ? { clientInputId: inputId } : {}),
|
|
1661
1964
|
};
|
|
1662
1965
|
if (attached.images.length) params.images = attached.images;
|
|
1663
|
-
|
|
1966
|
+
input = this.emit(convId, "main.input", {
|
|
1664
1967
|
origin: "user", input_ref: { message_seq: reference },
|
|
1665
1968
|
session_path: this.store.getConversation(convId)?.threadId, handoff,
|
|
1666
1969
|
...(!nativeCommand || rt.clientInputIdentity ? {
|
|
@@ -1677,9 +1980,20 @@ export class Hub {
|
|
|
1677
1980
|
this.decisions?.refresh();
|
|
1678
1981
|
} catch (error) {
|
|
1679
1982
|
if (generation !== this.generation || this.omoStatus !== "ready") return;
|
|
1983
|
+
// A retired session's late answer: retirement already closed its input, so it must not close or report it again.
|
|
1984
|
+
const retired = sessionId !== null && rt.sessionId !== sessionId;
|
|
1985
|
+
// Bug 224: the engine refused this prompt before accepting it, so it never runs and must not hold the turn.
|
|
1986
|
+
// Native commands ("." continues through a queued custom message) and unknown delivery stay open.
|
|
1987
|
+
const refused = !retired && input !== null && !nativeCommand && error instanceof OmoCommandError &&
|
|
1988
|
+
error.command === "prompt" && error.code === REQUIRED_COMPACTION_REFUSAL && !this.store.mainInputConsumed(input);
|
|
1989
|
+
if (refused && input) this.emit(convId, "main.input.handled", { input_seq: input.seq, reason: "prompt_rejected" });
|
|
1680
1990
|
if (protectedPayload) throw new SecurityRuntimeError("dispatch_unknown");
|
|
1991
|
+
if (retired) return;
|
|
1992
|
+
if (error instanceof AgentSessionError) return; // The definition failure was already persisted and broadcast.
|
|
1681
1993
|
const message = errorMessage(error);
|
|
1682
|
-
this.emit(convId, "error", this.continuityError(message));
|
|
1994
|
+
this.emit(convId, "error", refused ? { kind: "compaction-required", message: say(COMPACTION_REQUIRED_MESSAGE) } : this.continuityError(message));
|
|
1995
|
+
} finally {
|
|
1996
|
+
releaseAdmission();
|
|
1683
1997
|
}
|
|
1684
1998
|
}
|
|
1685
1999
|
|
|
@@ -1687,7 +2001,7 @@ export class Hub {
|
|
|
1687
2001
|
if (!CONTEXT_OVERFLOW.test(message)) return { message };
|
|
1688
2002
|
return {
|
|
1689
2003
|
kind: "continuity-protected",
|
|
1690
|
-
message:
|
|
2004
|
+
message: say(HUB_TEXT.continuityProtected, { message }),
|
|
1691
2005
|
};
|
|
1692
2006
|
}
|
|
1693
2007
|
|
|
@@ -1713,20 +2027,91 @@ export class Hub {
|
|
|
1713
2027
|
const rt = this.rt(convId);
|
|
1714
2028
|
if (!rt.sessionId || !rt.running) return;
|
|
1715
2029
|
await this.requireOmo().command("abort", { sessionId: rt.sessionId });
|
|
1716
|
-
this.dialogs.cancelScope(this.conversationScope(convId),
|
|
2030
|
+
this.dialogs.cancelScope(this.conversationScope(convId), say(HUB_TEXT.userStopped));
|
|
1717
2031
|
}
|
|
1718
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");
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
|
|
1719
2096
|
/** omo's slash commands and skills for the conversation's session, trimmed to what the input menu shows. */
|
|
1720
2097
|
async commands(convId: string): Promise<SlashCommand[]> {
|
|
1721
2098
|
const conv = this.store.getConversation(convId);
|
|
1722
2099
|
if (!conv) throw new Error(`unknown_conversation:${convId}`);
|
|
1723
2100
|
const { sessionId } = await this.ensureSession(conv);
|
|
1724
2101
|
const { commands } = await this.requireOmo().command<{ commands: { name: string; description?: string; source: string }[] }>("get_commands", { sessionId });
|
|
1725
|
-
const
|
|
1726
|
-
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];
|
|
1727
2105
|
return [...listed.filter((c) => !own.some((command) => command.name === c.name)), ...own];
|
|
1728
2106
|
}
|
|
1729
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
|
+
|
|
1730
2115
|
answerAction(data: ActionAnswerData, conv?: string): boolean {
|
|
1731
2116
|
if (this.maintenanceId) throw new MaintenanceError("maintenance_held");
|
|
1732
2117
|
if (this.decisions) {
|
|
@@ -1854,9 +2239,9 @@ export class Hub {
|
|
|
1854
2239
|
? { engineTurn: { id: rt.statsTurnId, sessionId: rt.durableId, messageTimestamp: message.timestamp, messageIndex: rt.statsMessageIndex } } : {}),
|
|
1855
2240
|
});
|
|
1856
2241
|
if (message.stopReason === "aborted") {
|
|
1857
|
-
this.emit(conv, "notice", { message:
|
|
2242
|
+
this.emit(conv, "notice", { message: say(HUB_TEXT.stopped), level: "info" });
|
|
1858
2243
|
} else if (message.stopReason === "error" || message.errorMessage) {
|
|
1859
|
-
this.emit(conv, "error", this.continuityError(message.errorMessage ??
|
|
2244
|
+
this.emit(conv, "error", this.continuityError(message.errorMessage ?? say(HUB_TEXT.modelResponseError)));
|
|
1860
2245
|
}
|
|
1861
2246
|
rt.itemId = null;
|
|
1862
2247
|
rt.itemText = "";
|
|
@@ -1887,9 +2272,9 @@ export class Hub {
|
|
|
1887
2272
|
case "compaction_start": {
|
|
1888
2273
|
if (!blockingCompaction(event) || this.compactionSeen(conv, event, false)) return;
|
|
1889
2274
|
const requestId = typeof event.requestId === "string" ? event.requestId : undefined;
|
|
1890
|
-
this.emit(conv, "session.compaction", { status: "running", requestId });
|
|
2275
|
+
this.emit(conv, "session.compaction", { status: "running", requestId, ...(event.reason === "manual" ? { reason: "manual" } : {}) });
|
|
1891
2276
|
this.emit(conv, "notice", { kind: "compaction-started", level: "info", requestId,
|
|
1892
|
-
message:
|
|
2277
|
+
message: say(HUB_TEXT.compactionStarted) });
|
|
1893
2278
|
return;
|
|
1894
2279
|
}
|
|
1895
2280
|
case "compaction_end": {
|
|
@@ -1898,21 +2283,25 @@ export class Hub {
|
|
|
1898
2283
|
const outcome = compactionOutcome(event);
|
|
1899
2284
|
this.emit(conv, "session.compaction", {
|
|
1900
2285
|
...outcome, requestId, accepted: outcome.status === "completed",
|
|
2286
|
+
...(event.reason === "manual" ? { reason: "manual" } : {}),
|
|
1901
2287
|
});
|
|
1902
2288
|
switch (outcome.status) {
|
|
1903
2289
|
case "cancelled":
|
|
1904
2290
|
this.emit(conv, "notice", { kind: "compaction-cancelled", level: "warning", requestId,
|
|
1905
|
-
message:
|
|
2291
|
+
message: say(HUB_TEXT.compactionCancelled) });
|
|
1906
2292
|
break;
|
|
1907
2293
|
case "failed": {
|
|
1908
2294
|
const recovery = COMPACTION_RECOVERY[outcome.failureKind];
|
|
1909
2295
|
this.emit(conv, "error", { kind: "compaction-failed", requestId, failureKind: outcome.failureKind,
|
|
1910
|
-
action: recovery.action, message:
|
|
2296
|
+
action: recovery.action, message: say(COMPACTION_FAILED, { recovery: say(recovery.message) }) });
|
|
2297
|
+
void this.logCompactionFailure(outcome.failureKind, event.failure)
|
|
2298
|
+
.catch((error) => console.error(`compaction failure log: ${errorMessage(error)}`));
|
|
2299
|
+
if (this.compactionFailuresInRow(conv) >= this.freshSessionCompactionFailures()) this.openFreshSession(conv);
|
|
1911
2300
|
break;
|
|
1912
2301
|
}
|
|
1913
2302
|
case "completed":
|
|
1914
2303
|
this.emit(conv, "notice", { kind: "compaction-completed", level: "info", requestId,
|
|
1915
|
-
message:
|
|
2304
|
+
message: say(HUB_TEXT.compactionCompleted) });
|
|
1916
2305
|
break;
|
|
1917
2306
|
}
|
|
1918
2307
|
return;
|
|
@@ -1923,7 +2312,7 @@ export class Hub {
|
|
|
1923
2312
|
rt.statsMessageIndex = 0;
|
|
1924
2313
|
this.publishActivity(conv);
|
|
1925
2314
|
this.unwatchConversation(conv);
|
|
1926
|
-
this.dialogs.cancelScope(owner.scope,
|
|
2315
|
+
this.dialogs.cancelScope(owner.scope, say(HUB_TEXT.turnEnded));
|
|
1927
2316
|
if (this.decisions) {
|
|
1928
2317
|
void this.reconcileDecisionMain(conv)
|
|
1929
2318
|
.then(() => this.decisions?.settled(owner.scope))
|
|
@@ -2000,7 +2389,7 @@ export class Hub {
|
|
|
2000
2389
|
legacyActionIds: [requestId],
|
|
2001
2390
|
method: method as DialogMethod,
|
|
2002
2391
|
permission,
|
|
2003
|
-
title: permission ?
|
|
2392
|
+
title: permission ? say(HUB_TEXT.permissionTitle, { tool: firstLine?.slice(PERMISSION_PREFIX.length).trim() ?? "" }) : (firstLine ?? ""),
|
|
2004
2393
|
detail: detailParts.filter(Boolean).join("\n\n"),
|
|
2005
2394
|
options: Array.isArray(event.options) ? (event.options as unknown[]).map(String) : [],
|
|
2006
2395
|
},
|
|
@@ -2112,12 +2501,12 @@ export class Hub {
|
|
|
2112
2501
|
this.questions.delete(id);
|
|
2113
2502
|
const historyActionId = this.legacyQuestionHistoryAliases.get(id) ?? id;
|
|
2114
2503
|
this.legacyQuestionHistoryAliases.delete(id);
|
|
2115
|
-
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" });
|
|
2116
2505
|
}
|
|
2117
2506
|
for (const id of this.openQuestionCards(owner.conv, owner.scope)) {
|
|
2118
2507
|
if (live.has(id)) continue;
|
|
2119
2508
|
if (this.store.getDecisionByAlias(owner.scope, id)?.sourceRequestId.startsWith("legacy:")) continue;
|
|
2120
|
-
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" });
|
|
2121
2510
|
}
|
|
2122
2511
|
}
|
|
2123
2512
|
|
|
@@ -2251,7 +2640,7 @@ export class Hub {
|
|
|
2251
2640
|
const recovered = this.emitMissedAnswers(conv.id, assistantTexts(currentEntries));
|
|
2252
2641
|
if (!this.hasUnfinishedTurn(conv.id)) return;
|
|
2253
2642
|
if (rt.running) {
|
|
2254
|
-
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) }) });
|
|
2255
2644
|
return;
|
|
2256
2645
|
}
|
|
2257
2646
|
if (turn.source.origin === "work_update") {
|
|
@@ -2273,12 +2662,12 @@ export class Hub {
|
|
|
2273
2662
|
this.emit(conv.id, "notice", {
|
|
2274
2663
|
kind: "user-input-receipt-unknown", level: "warning",
|
|
2275
2664
|
message_seq: turn.source.input_ref.message_seq,
|
|
2276
|
-
message:
|
|
2665
|
+
message: say(HUB_TEXT.sendReceiptUnknown),
|
|
2277
2666
|
});
|
|
2278
2667
|
return;
|
|
2279
2668
|
}
|
|
2280
2669
|
if (turnFinished(currentEntries)) {
|
|
2281
|
-
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) }) });
|
|
2282
2671
|
this.completeTurn(conv.id, "completed");
|
|
2283
2672
|
rt.wake?.release?.();
|
|
2284
2673
|
rt.wake = null;
|
|
@@ -2288,7 +2677,7 @@ export class Hub {
|
|
|
2288
2677
|
.eventsAfter(conv.id, this.store.lastSeq(conv.id, "message.user"))
|
|
2289
2678
|
.filter((e) => e.type === "notice" && (e.data as { kind?: string }).kind === "auto-continue").length;
|
|
2290
2679
|
if (tries >= AUTO_CONTINUE_LIMIT) {
|
|
2291
|
-
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 }) });
|
|
2292
2681
|
this.completeTurn(conv.id, "interrupted");
|
|
2293
2682
|
rt.wake?.release?.();
|
|
2294
2683
|
rt.wake = null;
|
|
@@ -2298,7 +2687,7 @@ export class Hub {
|
|
|
2298
2687
|
|| currentEntries.some((e) => e.type === "message" && e.role === "user");
|
|
2299
2688
|
const lastUser = this.store.getEvent(turn.source.input_ref.message_seq)?.data as { text?: string; images?: string[] } | undefined;
|
|
2300
2689
|
const message = promptReached || !lastUser?.text ? CONTINUE_PROMPT : lastUser.text;
|
|
2301
|
-
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) }) });
|
|
2302
2691
|
await this.prompt(conv.id, message, promptReached ? [] : lastUser?.images ?? [], turn.source.input_ref.message_seq);
|
|
2303
2692
|
}
|
|
2304
2693
|
|
|
@@ -2306,6 +2695,14 @@ export class Hub {
|
|
|
2306
2695
|
return this.store.mainTurn(conv) !== null || this.store.pendingMainUsers(conv).length > 0;
|
|
2307
2696
|
}
|
|
2308
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
|
+
|
|
2309
2706
|
private completeTurn(conv: string, status: "completed" | "interrupted", pendingInputSeqs: number[] = []): void {
|
|
2310
2707
|
const turn = this.store.mainTurn(conv);
|
|
2311
2708
|
const source = this.store.mainConsumedSource(conv);
|
|
@@ -2473,6 +2870,29 @@ export class Hub {
|
|
|
2473
2870
|
private openSession(conv: ConversationInfo) {
|
|
2474
2871
|
const storedPath = conv.threadId?.startsWith("/") ? conv.threadId : null;
|
|
2475
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
|
+
}
|
|
2476
2896
|
const opened = this.requireOmo().command<{ sessionId: string; attached?: boolean; state?: {
|
|
2477
2897
|
isStreaming?: boolean; pendingQuestions?: ScopedQuestionFrame[];
|
|
2478
2898
|
clientInputIdentity?: boolean; atomicQueuedResume?: boolean;
|
|
@@ -2480,15 +2900,18 @@ export class Hub {
|
|
|
2480
2900
|
} }>("open_session", {
|
|
2481
2901
|
sessionPath,
|
|
2482
2902
|
// Native ignores creation defaults when the session file already exists.
|
|
2483
|
-
...(
|
|
2484
|
-
|
|
2903
|
+
...(model ?? {}),
|
|
2904
|
+
...(typeof agent?.fields.thinking === "string" ? { thinkingLevel: agent.fields.thinking } : {}),
|
|
2905
|
+
cwd,
|
|
2485
2906
|
permissionPreset: this.options.permissionPreset,
|
|
2486
2907
|
retain_on_disconnect: true,
|
|
2487
2908
|
kind: "interactive",
|
|
2488
2909
|
promptSurface: "app",
|
|
2489
|
-
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" },
|
|
2490
2913
|
});
|
|
2491
|
-
return { sessionPath, opened };
|
|
2914
|
+
return { sessionPath, opened, agent };
|
|
2492
2915
|
}
|
|
2493
2916
|
|
|
2494
2917
|
private watchAsk(conv: string, toolCallId: string, args: unknown): void {
|
|
@@ -2533,6 +2956,7 @@ export class Hub {
|
|
|
2533
2956
|
|
|
2534
2957
|
private async ensureSession(conv: ConversationInfo, duringRotation = false): Promise<OpenResult> {
|
|
2535
2958
|
if (this.maintenanceId) throw new MaintenanceError("maintenance_held");
|
|
2959
|
+
if (!duringRotation) this.requireVisibleAgent(conv.id);
|
|
2536
2960
|
if (this.omoStatus !== "ready") throw new Error("omo_not_ready");
|
|
2537
2961
|
if (this.closeUncertain(conv.id)) throw new Error("session_close_unknown: source retained; verify native closure before retry");
|
|
2538
2962
|
const rt = this.rt(conv.id);
|
|
@@ -2540,9 +2964,9 @@ export class Hub {
|
|
|
2540
2964
|
if (rt.sessionId) return { sessionId: rt.sessionId, streaming: rt.running };
|
|
2541
2965
|
if (rt.opening) return rt.opening;
|
|
2542
2966
|
const generation = this.generation;
|
|
2543
|
-
const { sessionPath, opened } = this.openSession(conv);
|
|
2967
|
+
const { sessionPath, opened, agent } = this.openSession(conv);
|
|
2544
2968
|
const opening = opened
|
|
2545
|
-
.then((data) => {
|
|
2969
|
+
.then(async (data) => {
|
|
2546
2970
|
if (generation !== this.generation || this.omoStatus !== "ready") throw new Error("stale_open_session");
|
|
2547
2971
|
rt.sessionId = data.sessionId;
|
|
2548
2972
|
rt.durableId = typeof data.state?.sessionId === "string" ? data.state.sessionId : null;
|
|
@@ -2556,6 +2980,20 @@ export class Hub {
|
|
|
2556
2980
|
}
|
|
2557
2981
|
const owner = { conv: conv.id, scope: this.conversationScope(conv.id), sessionId: data.sessionId, sessionPath };
|
|
2558
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
|
+
}
|
|
2559
2997
|
this.syncScopedQuestions(owner, data.state?.pendingQuestions ?? []);
|
|
2560
2998
|
this.publishActivity(conv.id);
|
|
2561
2999
|
return { sessionId: data.sessionId, streaming: data.state?.isStreaming === true };
|
|
@@ -2577,7 +3015,7 @@ export class Hub {
|
|
|
2577
3015
|
if (!rt) {
|
|
2578
3016
|
rt = {
|
|
2579
3017
|
sessionId: null, opening: null, running: false, run: 0, rotating: false, admitting: 0, clientInputIdentity: false, atomicQueuedResume: false, durableId: null,
|
|
2580
|
-
waking: null, wake: null, notified: new Set(),
|
|
3018
|
+
waking: null, wake: null, notified: new Set(), promptAdmissions: new Set(),
|
|
2581
3019
|
itemId: null, itemText: "", statsTurnId: null, statsMessageIndex: 0,
|
|
2582
3020
|
rotationDone: null, finishRotation: null,
|
|
2583
3021
|
};
|
|
@@ -2632,9 +3070,9 @@ function unreachableQuestionText(args: unknown): string {
|
|
|
2632
3070
|
const questions = ((args as { questions?: unknown } | undefined)?.questions ?? []) as Partial<QuestionItem>[];
|
|
2633
3071
|
const lines = questions.map((q) => {
|
|
2634
3072
|
const options = (q.options ?? []).map((o) => o.label).join(" / ");
|
|
2635
|
-
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}` : ""}`;
|
|
2636
3074
|
});
|
|
2637
|
-
return [
|
|
3075
|
+
return [say(HUB_TEXT.questionUnreachable), ...lines].join("\n");
|
|
2638
3076
|
}
|
|
2639
3077
|
|
|
2640
3078
|
/** 2026-09-26T15:43:01.123Z -> 20260926T154301Z */
|
|
@@ -2705,8 +3143,10 @@ function turnFinished(entries: readonly LedgerEntry[]): boolean {
|
|
|
2705
3143
|
return last?.role === "assistant" && last.stopReason === "stop";
|
|
2706
3144
|
}
|
|
2707
3145
|
|
|
2708
|
-
|
|
2709
|
-
|
|
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 });
|
|
2710
3150
|
}
|
|
2711
3151
|
|
|
2712
3152
|
function errorMessage(error: unknown): string {
|