zigmini 0.9.0-beta.2 → 0.9.0-beta.3
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 +11 -1
- package/README.md +11 -1
- package/assets/android/zigmini.apk +0 -0
- package/assets/android/zigmini.apk.json +3 -3
- package/assets/build.json +1 -1
- package/host/omo-bridge/model-control.ts +15 -3
- 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/package.json +1 -1
- package/src/hub.ts +195 -15
- package/src/omo/client.ts +2 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": 1,
|
|
3
|
-
"revision":
|
|
3
|
+
"revision": 3,
|
|
4
4
|
"upstream": {
|
|
5
5
|
"omo": "5.1.48",
|
|
6
6
|
"senpi": "2026.10.25-2"
|
|
7
7
|
},
|
|
8
|
-
"candidate": "<zigmini home>/omo/5.1.48-zgw.
|
|
8
|
+
"candidate": "<zigmini home>/omo/5.1.48-zgw.3",
|
|
9
9
|
"pristine": "<zigmini home>/omo/5.1.48",
|
|
10
10
|
"entrypoint": "node_modules/@code-yeongyu/senpi/dist/cli.js",
|
|
11
11
|
"patchRoot": "node_modules",
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
"omo-5.1.48-rpc-port.patch",
|
|
14
14
|
"omo-5.1.48-compaction-port.patch",
|
|
15
15
|
"omo-5.1.48-provider-port.patch",
|
|
16
|
-
"omo-5.1.48-runtime-compat.patch"
|
|
16
|
+
"omo-5.1.48-runtime-compat.patch",
|
|
17
|
+
"omo-5.1.48-compaction-recovery.patch"
|
|
17
18
|
],
|
|
18
19
|
"files": [
|
|
19
20
|
{
|
|
@@ -24,7 +25,7 @@
|
|
|
24
25
|
{
|
|
25
26
|
"path": "node_modules/@code-yeongyu/senpi/dist/core/agent-session.js",
|
|
26
27
|
"originalSha256": "7275ef36a6e5368eaea0d507644208c406f83bfaee2004984b9f94ac348ecc02",
|
|
27
|
-
"patchedSha256": "
|
|
28
|
+
"patchedSha256": "312825f603d98af540512ff0ee1c02da9ae54d081535a7b851fd788c9b65937d"
|
|
28
29
|
},
|
|
29
30
|
{
|
|
30
31
|
"path": "node_modules/@code-yeongyu/senpi/dist/core/auth-storage.js",
|
|
@@ -79,7 +80,7 @@
|
|
|
79
80
|
{
|
|
80
81
|
"path": "node_modules/@code-yeongyu/senpi/dist/core/extensions/builtin/anthropic-subscription/stream.js",
|
|
81
82
|
"originalSha256": "600dd50df1f34d7367636510a5e63106d674024454ac0c0501d97a266098c489",
|
|
82
|
-
"patchedSha256": "
|
|
83
|
+
"patchedSha256": "86436c9ba31895ad99da8fd097b3a7dbbcfd71325a959ae574e93c642a2b4623"
|
|
83
84
|
},
|
|
84
85
|
{
|
|
85
86
|
"path": "node_modules/@code-yeongyu/senpi/dist/core/extensions/builtin/ask-user/pending.js",
|
|
@@ -114,7 +115,7 @@
|
|
|
114
115
|
{
|
|
115
116
|
"path": "node_modules/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/context-reduction-lifecycle.js",
|
|
116
117
|
"originalSha256": "6c2a30053a0ff1d8c2c16471ac329f9927777c821dd1fb3c5e73a8bbd7c9e06b",
|
|
117
|
-
"patchedSha256": "
|
|
118
|
+
"patchedSha256": "b6ebc56683a9abd768f4bc1dfaddaea28dbdc7a24586254729700836d93f3a5e"
|
|
118
119
|
},
|
|
119
120
|
{
|
|
120
121
|
"path": "node_modules/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/deterministic-fallback.js",
|
|
@@ -134,7 +135,7 @@
|
|
|
134
135
|
{
|
|
135
136
|
"path": "node_modules/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/index.js",
|
|
136
137
|
"originalSha256": "090f17d798e47486e038dc673cf5f20fbd66c54fda73460b20f0d8c770793645",
|
|
137
|
-
"patchedSha256": "
|
|
138
|
+
"patchedSha256": "861ecef9412c7dd7014f2424fc9be18fbec5668e65318b5f51075e1efa463489"
|
|
138
139
|
},
|
|
139
140
|
{
|
|
140
141
|
"path": "node_modules/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/lane-policy.js",
|
|
@@ -194,7 +195,7 @@
|
|
|
194
195
|
{
|
|
195
196
|
"path": "node_modules/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/speculative-summary.js",
|
|
196
197
|
"originalSha256": "25dc617e8b999f7b38e638287df81790768279aa78a607e692ccd2b108b88ada",
|
|
197
|
-
"patchedSha256": "
|
|
198
|
+
"patchedSha256": "103b36b2a4f2755d6ea349ac4e0c38b84058e88adaa15ddecc5605db15718638"
|
|
198
199
|
},
|
|
199
200
|
{
|
|
200
201
|
"path": "node_modules/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/speculative.d.ts",
|
|
@@ -204,7 +205,7 @@
|
|
|
204
205
|
{
|
|
205
206
|
"path": "node_modules/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/speculative.js",
|
|
206
207
|
"originalSha256": "55332e85de550ea1b3f0c25df4898897fbe646c468fe7bf82ddacb16742becc7",
|
|
207
|
-
"patchedSha256": "
|
|
208
|
+
"patchedSha256": "d3a50b066cee5d21a67af0dff3df7c4e1664159f6c2ad1376c405685f102e74a"
|
|
208
209
|
},
|
|
209
210
|
{
|
|
210
211
|
"path": "node_modules/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/task-intent.js",
|
|
@@ -249,12 +250,12 @@
|
|
|
249
250
|
{
|
|
250
251
|
"path": "node_modules/@code-yeongyu/senpi/dist/core/extensions/runner.js",
|
|
251
252
|
"originalSha256": "bdd693f5c91dcf507b81262b19c48c5e721070033a75a59de3b0407144fe4346",
|
|
252
|
-
"patchedSha256": "
|
|
253
|
+
"patchedSha256": "46ee33b918130620bd08c4ba26f402b8274c71e7f7ccf41f9bdc7ca775a439a7"
|
|
253
254
|
},
|
|
254
255
|
{
|
|
255
256
|
"path": "node_modules/@code-yeongyu/senpi/dist/core/extensions/types.d.ts",
|
|
256
257
|
"originalSha256": "2bce6e8eec55ef835de724b7b62c8b02afda8e445ad873633bf25da07d37ea1a",
|
|
257
|
-
"patchedSha256": "
|
|
258
|
+
"patchedSha256": "359a267b3aec385dacc2f5809addcf6d395eb96610c67cd2914053ecae1fb8ee"
|
|
258
259
|
},
|
|
259
260
|
{
|
|
260
261
|
"path": "node_modules/@code-yeongyu/senpi/dist/core/model-runtime.js",
|
package/package.json
CHANGED
package/src/hub.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { isVersionCommand, VERSION_COMMAND } from "./version.ts";
|
|
|
13
13
|
import { isUsageCommand, USAGE_COMMAND } from "./usage.ts";
|
|
14
14
|
import { MessageReferenceError, referencedInput, resolveMessageReferences } from "./message-references.ts";
|
|
15
15
|
import { readProfile } from "./profile.ts";
|
|
16
|
-
import type { MessageGuard } from "./security-check.ts";
|
|
16
|
+
import type { MessageGuard, SecurityChecker } from "./security-check.ts";
|
|
17
17
|
import { SecurityRuntimeError, type SecurityReceipt } from "./security-allow.ts";
|
|
18
18
|
import { nativeReplyEntry, type NativeReplyObserver } from "./reply-notifications.ts";
|
|
19
19
|
import { notificationHistory } from "./notification-history.ts";
|
|
@@ -52,7 +52,7 @@ export interface HubOptions {
|
|
|
52
52
|
/** Where attached images live (`dir`, as this process sees it) and the same folder's path on the Mac, for the agent. */
|
|
53
53
|
media?: { dir: string; hostDir?: string };
|
|
54
54
|
now?: () => number;
|
|
55
|
-
rotation?: { maxAgeMs?: number; idleMs?: number; forceMaxBytes?: number; forceUpdateCompactions?: number };
|
|
55
|
+
rotation?: { maxAgeMs?: number; idleMs?: number; forceMaxBytes?: number; forceUpdateCompactions?: number; freshSessionCompactionFailures?: number };
|
|
56
56
|
/** Bug 152: bounded native ownership reconciliation; timers are injectable for deterministic tests. */
|
|
57
57
|
closeRecovery?: {
|
|
58
58
|
intervalMs?: number;
|
|
@@ -87,9 +87,62 @@ const COMPACTION_RECOVERY = {
|
|
|
87
87
|
context_budget: { action: "retry", message: "요약 결과가 대화 모델의 입력 한도에 맞지 않습니다. 원인을 해결한 뒤 다시 진행해 주세요." },
|
|
88
88
|
stale: { action: "retry", message: "요약 중 대화가 바뀌었습니다. 최신 대화에서 다시 진행해 주세요." },
|
|
89
89
|
provider: { action: "retry", message: "원인을 해결한 뒤 다시 진행해 주세요." },
|
|
90
|
+
account_blocked: { action: "account", message: "요약 공급자의 계정 묶음이 요청을 보내지 않았습니다. 원인이 사용 한도인지 인증인지는 확정되지 않았습니다. 계정 상태를 확인하거나 다른 모델을 고른 뒤 다시 진행해 주세요." },
|
|
90
91
|
} as const satisfies Record<CompactionFailureKind, { readonly action: string; readonly message: string }>;
|
|
91
92
|
|
|
92
|
-
|
|
93
|
+
/** Bug 223: the longest provider error a compaction failure log line keeps, after its sensitive values are removed. */
|
|
94
|
+
export const COMPACTION_ERROR_MAX_CHARS = 512;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Bug 223: the provider's own error text with every value the repository security check finds (tokens, keys,
|
|
98
|
+
* credentials, home paths, e-mail, internal addresses, device ids) replaced by [redacted], on one line, at most
|
|
99
|
+
* COMPACTION_ERROR_MAX_CHARS. The cause wording stays. Values are found before the text is cut.
|
|
100
|
+
*/
|
|
101
|
+
export function sanitizeProviderError(text: unknown, checker: Pick<SecurityChecker, "runtimeOccurrences">): string | null {
|
|
102
|
+
if (typeof text !== "string") return null;
|
|
103
|
+
const withoutPrivateKeys = text.replace(/-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----[\s\S]*?(?:-----END (?:[A-Z0-9]+ )*PRIVATE KEY-----|$)/g, "[redacted]");
|
|
104
|
+
const flat = withoutPrivateKeys.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, " ").replace(/\s+/g, " ").trim();
|
|
105
|
+
if (!flat) return null;
|
|
106
|
+
const spans = checker.runtimeOccurrences([flat])
|
|
107
|
+
.map((found) => [found.start ?? 0, found.end ?? flat.length] as const).sort((a, b) => a[0] - b[0]);
|
|
108
|
+
let kept = "";
|
|
109
|
+
let at = 0;
|
|
110
|
+
for (const [start, end] of spans) {
|
|
111
|
+
if (end <= at) continue;
|
|
112
|
+
if (start >= at) kept += `${flat.slice(at, start)}[redacted]`;
|
|
113
|
+
at = end;
|
|
114
|
+
}
|
|
115
|
+
kept += flat.slice(at);
|
|
116
|
+
return kept.length > COMPACTION_ERROR_MAX_CHARS ? `${kept.slice(0, COMPACTION_ERROR_MAX_CHARS)}…` : kept;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Bug 223: the fields of one gateway log line per failed blocking compaction. A provider/model the engine did not list
|
|
121
|
+
* or a malformed number never reaches the log. The provider error is kept only through sanitizeProviderError;
|
|
122
|
+
* errorMessage and other free text are never read.
|
|
123
|
+
*/
|
|
124
|
+
export function compactionFailureLog(failureKind: CompactionFailureKind, failure: unknown, knownModels: ReadonlySet<string>,
|
|
125
|
+
sanitize: (text: unknown) => string | null = () => null): Record<string, unknown> {
|
|
126
|
+
const fields = typeof failure === "object" && failure !== null ? failure as Record<string, unknown> : {};
|
|
127
|
+
const provider = typeof fields.provider === "string" ? fields.provider : "";
|
|
128
|
+
const model = typeof fields.model === "string" ? fields.model : "";
|
|
129
|
+
const listed = knownModels.has(`${provider}/${model}`);
|
|
130
|
+
const tokens = fields.inputTokens;
|
|
131
|
+
const retryAt = fields.retryAt;
|
|
132
|
+
return {
|
|
133
|
+
failureKind,
|
|
134
|
+
provider: listed ? provider : "unknown",
|
|
135
|
+
model: listed ? model : "unknown",
|
|
136
|
+
error: sanitize(fields.error) ?? "unknown",
|
|
137
|
+
inputTokens: typeof tokens === "number" && Number.isSafeInteger(tokens) && tokens >= 0 ? tokens : "unknown",
|
|
138
|
+
inputTokensSource: fields.inputTokensSource === "estimate" || fields.inputTokensSource === "provider" ? fields.inputTokensSource : "unknown",
|
|
139
|
+
requestSent: typeof fields.requestSent === "boolean" ? fields.requestSent : "unknown",
|
|
140
|
+
...(typeof retryAt === "number" && Number.isSafeInteger(retryAt) && retryAt > 0 && retryAt < 8.64e15
|
|
141
|
+
? { retryAt: new Date(retryAt).toISOString() } : {}),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export type RotationReason = "manual" | "age" | "compaction_failed" | ForcedRotationReason;
|
|
93
146
|
/** Bug 72 proposal 4: limits that rotate a session even with works, updates, decisions or questions open. */
|
|
94
147
|
export type ForcedRotationReason = "size" | "compactions";
|
|
95
148
|
|
|
@@ -106,6 +159,10 @@ export const ROTATION_HELD_LOG_MS = 6 * 60 * 60 * 1000;
|
|
|
106
159
|
export const ROTATION_RETRY_MS = 30 * 60 * 1000;
|
|
107
160
|
/** Bug 72: completed turns that must carry an unconsumed user input before an idle native releases it from the turn. */
|
|
108
161
|
export const UNCONSUMED_INPUT_RELEASE_TURNS = 2;
|
|
162
|
+
/** Bug 224: the engine's exact refusal of a prompt before accepting it while compaction is unfinished (RequiredCompactionError). */
|
|
163
|
+
export const REQUIRED_COMPACTION_REFUSAL = "Context remains above the compaction threshold because compaction did not complete";
|
|
164
|
+
/** Bug 224 (user decision 2026-10-29): failed compactions in a row of one session that open a fresh session. */
|
|
165
|
+
export const FRESH_SESSION_COMPACTION_FAILURES = 2;
|
|
109
166
|
|
|
110
167
|
/** Bug 72: one condition that now holds a conversation's session rotation. */
|
|
111
168
|
export interface RotationBlocker {
|
|
@@ -147,6 +204,7 @@ function describeBlockers(blockers: readonly RotationBlocker[]): string {
|
|
|
147
204
|
const HANDOFF_MAX_CHARS = 8000;
|
|
148
205
|
const HANDOFF_LINE_CHARS = 600;
|
|
149
206
|
const CONTEXT_OVERFLOW = /prompt is too long|context overflow/i;
|
|
207
|
+
const COMPACTION_REQUIRED_MESSAGE = "대화 요약이 끝나지 않아 엔진이 이 메시지를 받지 않았습니다. 메시지는 기록에 남아 있고 자동으로 다시 보내지 않습니다. 요약 실패 원인을 해결하거나 '새 세션'을 눌러 주세요.";
|
|
150
208
|
|
|
151
209
|
interface OpenResult {
|
|
152
210
|
sessionId: string;
|
|
@@ -189,6 +247,7 @@ interface ConvRuntime {
|
|
|
189
247
|
run: number;
|
|
190
248
|
rotating: boolean;
|
|
191
249
|
admitting: number;
|
|
250
|
+
promptAdmissions: Set<() => void>;
|
|
192
251
|
clientInputIdentity: boolean;
|
|
193
252
|
atomicQueuedResume: boolean;
|
|
194
253
|
/** `open_session` state.sessionId: the session file's own identity, which survives reconnects. */
|
|
@@ -659,6 +718,8 @@ export class Hub {
|
|
|
659
718
|
|
|
660
719
|
/** Whether the last model answer named a held switch; the next settled main turn re-reads it. */
|
|
661
720
|
private modelPending = false;
|
|
721
|
+
private readonly knownModels = new Set<string>();
|
|
722
|
+
private providerErrorChecker: SecurityChecker | null = null;
|
|
662
723
|
|
|
663
724
|
/** The models omo can use right now (its logged-in providers) and the one a conversation's session is on. */
|
|
664
725
|
async models(convId = "main"): Promise<ModelInfo> {
|
|
@@ -671,6 +732,7 @@ export class Hub {
|
|
|
671
732
|
omo.command<{ model?: OmoModel | null; thinkingLevel?: string; pendingModelSwitch?: { provider: string; id: string } | null }>("get_state", { sessionId }),
|
|
672
733
|
]);
|
|
673
734
|
const options = modelOptions(available.models ?? []);
|
|
735
|
+
for (const option of options) this.knownModels.add(`${option.provider}/${option.id}`);
|
|
674
736
|
const held = state.pendingModelSwitch;
|
|
675
737
|
const pending = held
|
|
676
738
|
? options.find((o) => o.provider === held.provider && o.id === held.id) ?? { provider: held.provider, id: held.id, name: held.id }
|
|
@@ -1185,7 +1247,8 @@ export class Hub {
|
|
|
1185
1247
|
if (!conv) throw new Error(`unknown_conversation:${convId}`);
|
|
1186
1248
|
const rt = this.rt(convId);
|
|
1187
1249
|
const forced = reason === "size" || reason === "compactions";
|
|
1188
|
-
|
|
1250
|
+
const residue = this.residualTurn(convId, reason);
|
|
1251
|
+
if (rt.rotating || (this.rotationHeldFor(convId, forced) && residue === null)) throw new Error("turn_running");
|
|
1189
1252
|
if (this.omoStatus !== "ready" || this.closeUncertain(convId)) throw new Error("session_ownership_unknown");
|
|
1190
1253
|
const generation = this.generation;
|
|
1191
1254
|
const from = conv.threadId;
|
|
@@ -1243,7 +1306,7 @@ export class Hub {
|
|
|
1243
1306
|
rt.sessionId !== sessionId ||
|
|
1244
1307
|
from !== source ||
|
|
1245
1308
|
this.store.lastSeq(convId, "message.user") !== admittedUser ||
|
|
1246
|
-
this.rotationHeldFor(convId, forced)
|
|
1309
|
+
(this.rotationHeldFor(convId, forced) && (residue === null || this.residualTurn(convId, reason) !== residue))
|
|
1247
1310
|
) {
|
|
1248
1311
|
throw new Error("turn_running");
|
|
1249
1312
|
}
|
|
@@ -1288,6 +1351,92 @@ export class Hub {
|
|
|
1288
1351
|
}
|
|
1289
1352
|
}
|
|
1290
1353
|
|
|
1354
|
+
/**
|
|
1355
|
+
* Bug 224: a manual rotation may retire a session whose main turn left only residue: inputs nothing runs any more and
|
|
1356
|
+
* a work wake whose send already ended. Returns that residue's identity, or null when anything else holds. rotateSession
|
|
1357
|
+
* still proves the engine idle (no streaming, compaction, retry, queue or question) before it closes the session.
|
|
1358
|
+
*/
|
|
1359
|
+
private residualTurn(conv: string, reason: RotationReason): string | null {
|
|
1360
|
+
if (reason !== "manual") return null;
|
|
1361
|
+
const rt = this.rt(conv);
|
|
1362
|
+
const holds = this.rotationHoldsFor(conv, false);
|
|
1363
|
+
if (holds.length === 0 || this.store.pendingMainUsers(conv).length > 0) return null;
|
|
1364
|
+
const residual = holds.every((hold) => hold.kind === "main_turn" &&
|
|
1365
|
+
(hold.state === "unfinished_turn" || (hold.state === "waking" && rt.waking === null)));
|
|
1366
|
+
if (!residual) return null;
|
|
1367
|
+
const turn = this.store.mainTurn(conv);
|
|
1368
|
+
return JSON.stringify({ turn: turn?.seq ?? null, inputs: turn?.inputs.map((input) => input.seq) ?? [], wake: rt.wake?.input.seq ?? null });
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
/**
|
|
1372
|
+
* Bug 224 (user decision 2026-10-29): FRESH_SESSION_COMPACTION_FAILURES failed compactions in a row open a fresh session
|
|
1373
|
+
* without asking the old one for anything. The old session is detached first, so its late events never reach this
|
|
1374
|
+
* conversation; stopping and closing it are best effort. Its file stays; the handoff names it and carries only the
|
|
1375
|
+
* recent exchange. Works, decisions and never-sent user input carry over as in any rotation; nothing is replayed.
|
|
1376
|
+
*/
|
|
1377
|
+
private openFreshSession(convId: string): void {
|
|
1378
|
+
if (this.maintenanceId || this.omoStatus !== "ready") return;
|
|
1379
|
+
const conv = this.store.getConversation(convId);
|
|
1380
|
+
const rt = this.rt(convId);
|
|
1381
|
+
if (!conv?.threadId || rt.rotating) return;
|
|
1382
|
+
const from = conv.threadId;
|
|
1383
|
+
const sessionId = rt.sessionId;
|
|
1384
|
+
// 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: "세션 교체 중" }, false);
|
|
1386
|
+
try {
|
|
1387
|
+
if (sessionId) this.retireScopedQuestions(convId, sessionId);
|
|
1388
|
+
rt.running = false;
|
|
1389
|
+
rt.durableId = null;
|
|
1390
|
+
rt.run++;
|
|
1391
|
+
this.finishRetirement(convId, {
|
|
1392
|
+
sessionId: sessionId ?? "", sessionPath: from, from, reason: "compaction_failed",
|
|
1393
|
+
handoff: this.buildHandoff(convId, "compaction_failed", from),
|
|
1394
|
+
});
|
|
1395
|
+
this.publishActivity(convId);
|
|
1396
|
+
} finally {
|
|
1397
|
+
this.emit(convId, "session.transition", { active: false, label: null }, false);
|
|
1398
|
+
}
|
|
1399
|
+
if (sessionId) void this.stopAbandonedSession(sessionId);
|
|
1400
|
+
if (this.store.pendingMainUsers(convId).length > 0) void this.recover();
|
|
1401
|
+
void this.notifyWorkUpdates(convId);
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
/** A detached session gets one abort and one close; it no longer routes to any conversation, so failures only log. */
|
|
1405
|
+
private async stopAbandonedSession(sessionId: string): Promise<void> {
|
|
1406
|
+
for (const command of ["abort", "close_session"] as const) {
|
|
1407
|
+
try {
|
|
1408
|
+
await this.requireOmo().command(command, { sessionId });
|
|
1409
|
+
} catch (error) {
|
|
1410
|
+
console.error(`abandoned session ${command} failed (${sessionId}): ${errorMessage(error)}`);
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
/** Bug 223: log with cached model identities; an unhealthy engine must never hold its own failure diagnostic. */
|
|
1416
|
+
private async logCompactionFailure(failureKind: CompactionFailureKind, failure: unknown): Promise<void> {
|
|
1417
|
+
// Loaded on first use: the security rules ship beside src/, and only a failure needs them.
|
|
1418
|
+
if (!this.providerErrorChecker) this.providerErrorChecker = (await import("./security-check.ts")).defaultChecker();
|
|
1419
|
+
const checker = this.providerErrorChecker;
|
|
1420
|
+
console.error("compaction_failed", JSON.stringify(compactionFailureLog(failureKind, failure, this.knownModels,
|
|
1421
|
+
(text) => sanitizeProviderError(text, checker))));
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
private freshSessionCompactionFailures(): number {
|
|
1425
|
+
return this.options.rotation?.freshSessionCompactionFailures ?? FRESH_SESSION_COMPACTION_FAILURES;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
/** Bug 224: failed blocking compactions since the last completed one in this session, one per native request id. */
|
|
1429
|
+
private compactionFailuresInRow(conv: string): number {
|
|
1430
|
+
const failed = new Set<string>();
|
|
1431
|
+
for (const event of this.store.eventsAfter(conv, this.store.lastSeq(conv, "session.rotated")).reverse()) {
|
|
1432
|
+
if (event.type !== "session.compaction") continue;
|
|
1433
|
+
const data = event.data as { status?: string; requestId?: unknown };
|
|
1434
|
+
if (data.status === "completed") break;
|
|
1435
|
+
if (data.status === "failed" && typeof data.requestId === "string") failed.add(data.requestId);
|
|
1436
|
+
}
|
|
1437
|
+
return failed.size;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1291
1440
|
private closeUncertain(conv: string): boolean {
|
|
1292
1441
|
return this.store.lastSeq(conv, "session.close.unknown") >
|
|
1293
1442
|
Math.max(this.store.lastSeq(conv, "session.rotated"), this.store.lastSeq(conv, "session.close.resolved"));
|
|
@@ -1304,9 +1453,16 @@ export class Hub {
|
|
|
1304
1453
|
|
|
1305
1454
|
private finishRetirement(convId: string, retirement: SessionRetirement): void {
|
|
1306
1455
|
const rt = this.rt(convId);
|
|
1307
|
-
const
|
|
1308
|
-
|
|
1309
|
-
|
|
1456
|
+
for (const release of rt.promptAdmissions) release();
|
|
1457
|
+
if (retirement.sessionId) {
|
|
1458
|
+
const owner = this.ownerForConversation(convId, retirement.sessionId);
|
|
1459
|
+
this.sessionToConv.delete(retirement.sessionId);
|
|
1460
|
+
if (owner) this.releaseScopedUiOwner(owner);
|
|
1461
|
+
}
|
|
1462
|
+
// Bug 224: nothing the retired session left unfinished can run any more; close it so recovery never replays it.
|
|
1463
|
+
if (this.store.mainTurn(convId)) this.completeTurn(convId, "interrupted");
|
|
1464
|
+
rt.wake?.release?.();
|
|
1465
|
+
rt.wake = null;
|
|
1310
1466
|
rt.sessionId = null;
|
|
1311
1467
|
rt.itemId = null;
|
|
1312
1468
|
rt.itemText = "";
|
|
@@ -1318,7 +1474,8 @@ export class Hub {
|
|
|
1318
1474
|
this.emit(convId, "conversation.thread", { threadId: to });
|
|
1319
1475
|
this.emit(convId, "session.rotated", { reason: retirement.reason, from: retirement.from, to });
|
|
1320
1476
|
this.emit(convId, "notice", { kind: "session-rotated", level: "info",
|
|
1321
|
-
message: `새 엔진 세션으로 교체했습니다 (${this.reasonLabel(retirement.reason)}). 화면의 대화 기록은 그대로이고, 다음 메시지부터 새 세션에서
|
|
1477
|
+
message: `새 엔진 세션으로 교체했습니다 (${this.reasonLabel(retirement.reason)}). 화면의 대화 기록은 그대로이고, 다음 메시지부터 새 세션에서 이어집니다.${
|
|
1478
|
+
retirement.reason === "compaction_failed" ? " 이전 세션 파일은 그대로 남겨 두었고, 받지 못한 메시지는 자동으로 다시 보내지 않습니다." : ""}` });
|
|
1322
1479
|
}
|
|
1323
1480
|
|
|
1324
1481
|
private closeTimer(callback: () => void, ms: number): ReturnType<typeof setTimeout> {
|
|
@@ -1595,6 +1752,7 @@ export class Hub {
|
|
|
1595
1752
|
const hours = Math.round((this.options.rotation?.maxAgeMs ?? ROTATE_MAX_AGE_MS) / 3_600_000);
|
|
1596
1753
|
if (reason === "size") return `세션 파일이 ${Math.round((this.options.rotation?.forceMaxBytes ?? FORCE_ROTATE_MAX_BYTES) / 1024 / 1024)}MB에 닿음`;
|
|
1597
1754
|
if (reason === "compactions") return `압축이 ${this.options.rotation?.forceUpdateCompactions ?? FORCE_ROTATE_UPDATE_COMPACTIONS}번 더 이어짐`;
|
|
1755
|
+
if (reason === "compaction_failed") return `대화 요약이 ${this.freshSessionCompactionFailures()}번 연속 실패함`;
|
|
1598
1756
|
return reason === "manual" ? "직접 요청" : `세션이 ${hours}시간을 넘겨 쉬는 중`;
|
|
1599
1757
|
}
|
|
1600
1758
|
|
|
@@ -1617,7 +1775,7 @@ export class Hub {
|
|
|
1617
1775
|
}
|
|
1618
1776
|
return [
|
|
1619
1777
|
`[게이트웨이 알림] 이 대화는 새 엔진 세션으로 교체됐습니다 (${this.reasonLabel(reason)}).`,
|
|
1620
|
-
from ? `이전 세션의 전체 기록은 ${from} 에 남아 있습니다. 파일이 크니 필요할 때 필요한 부분만 검색해서
|
|
1778
|
+
from ? `이전 세션의 전체 기록은 ${from} 에 남아 있습니다. 파일이 크니 필요할 때 끝부분이나 필요한 부분만 검색해서 보고, 전체를 읽지 마세요.` : "",
|
|
1621
1779
|
...this.handoffOpenItems(conv),
|
|
1622
1780
|
"아래는 직전 대화의 최근 부분입니다. 맥락으로만 참고하세요.",
|
|
1623
1781
|
"---",
|
|
@@ -1635,14 +1793,23 @@ export class Hub {
|
|
|
1635
1793
|
const rt = this.rt(convId);
|
|
1636
1794
|
if (rt.rotating) return; // Accepted user intent remains definitely unsent until rotation finishes.
|
|
1637
1795
|
const generation = this.generation;
|
|
1796
|
+
let input: StoredEvent | null = null;
|
|
1797
|
+
let nativeCommand = false;
|
|
1798
|
+
let sessionId: string | null = null;
|
|
1799
|
+
const releaseAdmission = () => {
|
|
1800
|
+
if (rt.promptAdmissions.delete(releaseAdmission)) rt.admitting -= 1;
|
|
1801
|
+
};
|
|
1802
|
+
rt.promptAdmissions.add(releaseAdmission);
|
|
1803
|
+
// Hold user admission through RPC settlement, including the idle-before-agent_start interval.
|
|
1804
|
+
rt.admitting += 1;
|
|
1638
1805
|
try {
|
|
1639
|
-
|
|
1806
|
+
({ sessionId } = await this.ensureSession(conv));
|
|
1640
1807
|
if (generation !== this.generation || this.omoStatus !== "ready") return;
|
|
1641
1808
|
const reference = messageSeq ?? this.store.lastSeq(convId, "message.user");
|
|
1642
1809
|
// sendText and reconnect recovery can share admission; only one may cross the write boundary.
|
|
1643
1810
|
if (admitted && this.store.mainUserDispatched(convId, reference)) return;
|
|
1644
1811
|
const commandName = body.slice(1).split(" ", 1)[0];
|
|
1645
|
-
|
|
1812
|
+
nativeCommand = (body === "." && images.length === 0) ||
|
|
1646
1813
|
(body.startsWith("/") && (await this.commands(convId)).some((command) => command.name === commandName));
|
|
1647
1814
|
if (generation !== this.generation || this.omoStatus !== "ready" || rt.sessionId !== sessionId) return;
|
|
1648
1815
|
if (admitted && this.store.mainUserDispatched(convId, reference)) return;
|
|
@@ -1660,7 +1827,7 @@ export class Hub {
|
|
|
1660
1827
|
...(rt.clientInputIdentity ? { clientInputId: inputId } : {}),
|
|
1661
1828
|
};
|
|
1662
1829
|
if (attached.images.length) params.images = attached.images;
|
|
1663
|
-
|
|
1830
|
+
input = this.emit(convId, "main.input", {
|
|
1664
1831
|
origin: "user", input_ref: { message_seq: reference },
|
|
1665
1832
|
session_path: this.store.getConversation(convId)?.threadId, handoff,
|
|
1666
1833
|
...(!nativeCommand || rt.clientInputIdentity ? {
|
|
@@ -1677,9 +1844,19 @@ export class Hub {
|
|
|
1677
1844
|
this.decisions?.refresh();
|
|
1678
1845
|
} catch (error) {
|
|
1679
1846
|
if (generation !== this.generation || this.omoStatus !== "ready") return;
|
|
1847
|
+
// A retired session's late answer: retirement already closed its input, so it must not close or report it again.
|
|
1848
|
+
const retired = sessionId !== null && rt.sessionId !== sessionId;
|
|
1849
|
+
// Bug 224: the engine refused this prompt before accepting it, so it never runs and must not hold the turn.
|
|
1850
|
+
// Native commands ("." continues through a queued custom message) and unknown delivery stay open.
|
|
1851
|
+
const refused = !retired && input !== null && !nativeCommand && error instanceof OmoCommandError &&
|
|
1852
|
+
error.command === "prompt" && error.code === REQUIRED_COMPACTION_REFUSAL && !this.store.mainInputConsumed(input);
|
|
1853
|
+
if (refused && input) this.emit(convId, "main.input.handled", { input_seq: input.seq, reason: "prompt_rejected" });
|
|
1680
1854
|
if (protectedPayload) throw new SecurityRuntimeError("dispatch_unknown");
|
|
1855
|
+
if (retired) return;
|
|
1681
1856
|
const message = errorMessage(error);
|
|
1682
|
-
this.emit(convId, "error", this.continuityError(message));
|
|
1857
|
+
this.emit(convId, "error", refused ? { kind: "compaction-required", message: COMPACTION_REQUIRED_MESSAGE } : this.continuityError(message));
|
|
1858
|
+
} finally {
|
|
1859
|
+
releaseAdmission();
|
|
1683
1860
|
}
|
|
1684
1861
|
}
|
|
1685
1862
|
|
|
@@ -1908,6 +2085,9 @@ export class Hub {
|
|
|
1908
2085
|
const recovery = COMPACTION_RECOVERY[outcome.failureKind];
|
|
1909
2086
|
this.emit(conv, "error", { kind: "compaction-failed", requestId, failureKind: outcome.failureKind,
|
|
1910
2087
|
action: recovery.action, message: `대화 요약에 실패했습니다. 이전 내용은 유지했습니다. ${recovery.message}` });
|
|
2088
|
+
void this.logCompactionFailure(outcome.failureKind, event.failure)
|
|
2089
|
+
.catch((error) => console.error(`compaction failure log: ${errorMessage(error)}`));
|
|
2090
|
+
if (this.compactionFailuresInRow(conv) >= this.freshSessionCompactionFailures()) this.openFreshSession(conv);
|
|
1911
2091
|
break;
|
|
1912
2092
|
}
|
|
1913
2093
|
case "completed":
|
|
@@ -2577,7 +2757,7 @@ export class Hub {
|
|
|
2577
2757
|
if (!rt) {
|
|
2578
2758
|
rt = {
|
|
2579
2759
|
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(),
|
|
2760
|
+
waking: null, wake: null, notified: new Set(), promptAdmissions: new Set(),
|
|
2581
2761
|
itemId: null, itemText: "", statsTurnId: null, statsMessageIndex: 0,
|
|
2582
2762
|
rotationDone: null, finishRotation: null,
|
|
2583
2763
|
};
|
package/src/omo/client.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface OmoEvent {
|
|
|
25
25
|
export const COMPACTION_FAILURE_KINDS = [
|
|
26
26
|
"invalid_model", "auth_missing", "auth_expired", "quota", "org_denied", "model_unavailable",
|
|
27
27
|
"refused", "timeout", "incomplete", "empty", "context_budget", "stale", "provider",
|
|
28
|
+
// Bug 223: the provider's local account pool refused to send; quota versus auth is not known.
|
|
29
|
+
"account_blocked",
|
|
28
30
|
] as const;
|
|
29
31
|
export type CompactionFailureKind = typeof COMPACTION_FAILURE_KINDS[number];
|
|
30
32
|
export type CompactionOutcome =
|