zigmini 0.8.0-beta.6 → 0.9.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 +90 -19
- package/README.md +91 -19
- package/assets/agent/gateway-request-rules.md +3 -0
- package/assets/agent/gateway-rules.md +6 -0
- package/assets/android/zigmini.apk +0 -0
- package/assets/android/zigmini.apk.json +3 -3
- package/assets/build.json +1 -1
- package/docs/manual.ko.md +69 -1
- package/docs/manual.md +67 -1
- package/host/omo-bridge/task-control.ts +4 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-idle.patch +77 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-intent.patch +212 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-model.patch +299 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-preservation.patch +1063 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-refusal.patch +83 -0
- package/host/omo-check/patches/omo-5.1.35-gateway-port.manifest.json +169 -7
- package/host/omo-check/patches/omo-5.1.35-request-rules.patch +109 -0
- package/host/omo-check/patches/omo-5.1.35-turn-stats.patch +76 -0
- package/host/omo-check/patches/omo-5.1.44-compaction-port.patch +2630 -0
- package/host/omo-check/patches/omo-5.1.44-gateway-port.manifest.json +329 -0
- package/host/omo-check/patches/omo-5.1.44-provider-port.patch +440 -0
- package/host/omo-check/patches/omo-5.1.44-rpc-port.patch +778 -0
- package/package.json +4 -1
- package/scripts/secret-rules.ts +101 -0
- package/scripts/secret-scan.allow.json +155 -0
- package/scripts/secret-scan.rules.json +30 -0
- package/src/agent-request-rules.ts +82 -0
- package/src/cli/access.ts +31 -34
- package/src/cli/launchd.ts +30 -4
- package/src/cli/main.ts +3 -43
- package/src/cli/messages.ts +160 -0
- package/src/cli/pair.ts +32 -25
- package/src/cli/setup.ts +164 -75
- package/src/decisions.ts +48 -10
- package/src/engine/compat.json +3 -3
- package/src/hub.ts +183 -47
- package/src/main.ts +60 -4
- package/src/notification-history.ts +56 -0
- package/src/omo/client.ts +28 -0
- package/src/protocol.ts +32 -2
- package/src/reply-notifications.ts +144 -0
- package/src/runtime/paths.ts +2 -0
- package/src/security-allow.ts +126 -0
- package/src/security-check.ts +559 -0
- package/src/store.ts +236 -6
- package/src/turn-stats.ts +49 -0
- package/src/work-watch.ts +36 -10
- package/src/work.ts +45 -6
- package/web/DESIGN.md +39 -0
- package/web/app.js +289 -2
- package/web/decisions.d.ts +1 -1
- package/web/decisions.js +6 -0
- package/web/diagram.d.ts +28 -0
- package/web/diagram.js +228 -0
- package/web/index.html +29 -0
- package/web/notifications.d.ts +25 -0
- package/web/notifications.js +118 -0
- package/web/security-inbox.d.ts +35 -0
- package/web/security-inbox.js +237 -0
- package/web/style.css +43 -1
- package/web/turn-stats.d.ts +26 -0
- package/web/turn-stats.js +58 -0
package/src/decisions.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { isDeepStrictEqual } from "node:util";
|
|
2
2
|
import { decisionContext } from "./decision-context.ts";
|
|
3
|
+
import { SecurityCheckError, type MessageGuard } from "./security-check.ts";
|
|
4
|
+
import { securityDecision, SecurityRuntimeError } from "./security-allow.ts";
|
|
3
5
|
import type { OmoEvent } from "./omo/client.ts";
|
|
4
6
|
import type { ActionAnswerData, DecisionAnswerRequest, DecisionInfo, DecisionsPageData, DecisionUpdatedData } from "./protocol.ts";
|
|
5
7
|
import { blockedMainQuestion, blockedQuestion, DecisionStoreError, RESUMABLE_CLOSED, Store, type Decision, type DecisionMethod, type DecisionSubmission, type StoredEvent } from "./store.ts";
|
|
@@ -35,6 +37,15 @@ export interface DecisionServiceOptions {
|
|
|
35
37
|
readonly receiptTimeoutMs?: number;
|
|
36
38
|
readonly onHistory?: (event: StoredEvent) => void;
|
|
37
39
|
readonly onError?: (error: unknown) => void;
|
|
40
|
+
/** Improvement 165: the security check of answer text (value, comment, free text of each question). */
|
|
41
|
+
readonly guard?: Pick<MessageGuard, "holdOrPass">;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function answerTexts(request: DecisionAnswerRequest): string[] {
|
|
45
|
+
if (!("response" in request)) return [];
|
|
46
|
+
const response = request.response as { value?: unknown; comment?: unknown; answers?: Record<string, { text?: unknown }> };
|
|
47
|
+
const texts = [response.value, response.comment, ...Object.values(response.answers ?? {}).map((answer) => answer.text)];
|
|
48
|
+
return texts.filter((text): text is string => typeof text === "string");
|
|
38
49
|
}
|
|
39
50
|
|
|
40
51
|
function record(value: unknown): value is Record<string, unknown> {
|
|
@@ -153,6 +164,10 @@ export class DecisionService {
|
|
|
153
164
|
}
|
|
154
165
|
|
|
155
166
|
info(decision: Decision): DecisionInfo {
|
|
167
|
+
if (securityDecision(decision)) {
|
|
168
|
+
const security = this.store.securityHold(decision.id)?.state;
|
|
169
|
+
return { ...decision, work: null, allowedActions: [], ...(security ? { security } : {}) };
|
|
170
|
+
}
|
|
156
171
|
const work = decision.workId === null ? null : this.store.getWork(decision.workId);
|
|
157
172
|
let allowedActions: DecisionInfo["allowedActions"] = [];
|
|
158
173
|
if (unresolved(decision) && !["completed", "failed", "cancelled"].includes(work?.status ?? "") &&
|
|
@@ -189,8 +204,19 @@ export class DecisionService {
|
|
|
189
204
|
return this.info(this.required(id));
|
|
190
205
|
}
|
|
191
206
|
|
|
192
|
-
answer(id: string, input: unknown): DecisionInfo {
|
|
207
|
+
answer(id: string, input: unknown, checked = false): DecisionInfo {
|
|
208
|
+
const original = this.store.getDecision(id);
|
|
209
|
+
if (original && securityDecision(original)) throw new DecisionStoreError("security_endpoint_required");
|
|
193
210
|
const request = parseDecisionAnswer(input);
|
|
211
|
+
if (!checked) {
|
|
212
|
+
const owner = this.store.getDecision(id)?.ownerConv ?? "main";
|
|
213
|
+
const snapshot = structuredClone(request);
|
|
214
|
+
this.options.guard?.holdOrPass(owner, "answer", answerTexts(request), async (receipt) => {
|
|
215
|
+
receipt({ kind: "answer", decisionId: id, submissionId: snapshot.submissionId });
|
|
216
|
+
this.answer(id, snapshot, true);
|
|
217
|
+
}, { key: JSON.stringify(["answer", id, request.submissionId]), snapshot,
|
|
218
|
+
originalDecisionId: id, submissionId: request.submissionId });
|
|
219
|
+
}
|
|
194
220
|
this.refresh();
|
|
195
221
|
const before = this.required(id);
|
|
196
222
|
const binding = this.ports.liveBinding(before);
|
|
@@ -212,15 +238,15 @@ export class DecisionService {
|
|
|
212
238
|
try {
|
|
213
239
|
if (!this.ports.sendLive(decision)) this.review(id, "receipt_unknown");
|
|
214
240
|
} catch (error) {
|
|
215
|
-
this.error(error);
|
|
241
|
+
this.error(checked ? new SecurityRuntimeError("dispatch_unknown") : error);
|
|
216
242
|
this.review(id, "receipt_unknown");
|
|
217
243
|
}
|
|
218
244
|
} else if (operation?.data.mainInputSeq !== null && operation?.data.mainInputSeq !== undefined) {
|
|
219
245
|
const inputEvent = this.store.getEvent(operation.data.mainInputSeq);
|
|
220
246
|
if (!inputEvent) throw new Error("decision_main_intent_missing");
|
|
221
|
-
this.dispatch(id, () => this.ports.dispatchMain(inputEvent));
|
|
247
|
+
this.dispatch(id, () => this.ports.dispatchMain(inputEvent), checked);
|
|
222
248
|
} else if (operation?.data.commandId && decision.workId) {
|
|
223
|
-
this.dispatch(id, () => this.ports.dispatchWork(decision.workId!));
|
|
249
|
+
this.dispatch(id, () => this.ports.dispatchWork(decision.workId!), checked);
|
|
224
250
|
}
|
|
225
251
|
this.refresh();
|
|
226
252
|
return this.info(this.required(id));
|
|
@@ -231,6 +257,7 @@ export class DecisionService {
|
|
|
231
257
|
* 취소 button; a later answer to the closed decision is rejected as a conflict.
|
|
232
258
|
*/
|
|
233
259
|
dismiss(id: string, input: unknown): DecisionInfo {
|
|
260
|
+
if (securityDecision(this.required(id))) throw new DecisionStoreError("security_endpoint_required");
|
|
234
261
|
const expectedRevision = parseDecisionDismiss(input);
|
|
235
262
|
this.refresh();
|
|
236
263
|
let dismissal;
|
|
@@ -258,9 +285,9 @@ export class DecisionService {
|
|
|
258
285
|
return this.info(this.required(id));
|
|
259
286
|
}
|
|
260
287
|
|
|
261
|
-
private dispatch(id: string, send: () => Promise<void
|
|
288
|
+
private dispatch(id: string, send: () => Promise<void>, protectedPayload = false): void {
|
|
262
289
|
void send().catch((error: unknown) => {
|
|
263
|
-
this.error(error);
|
|
290
|
+
this.error(protectedPayload ? new SecurityRuntimeError("dispatch_unknown") : error);
|
|
264
291
|
if (!this.stopped) this.review(id, "receipt_unknown");
|
|
265
292
|
}).finally(() => {
|
|
266
293
|
if (!this.stopped) this.refresh();
|
|
@@ -507,6 +534,7 @@ export class DecisionService {
|
|
|
507
534
|
this.store.reconcileDecisionReceipts();
|
|
508
535
|
const now = Date.now();
|
|
509
536
|
for (let decision of this.store.allDecisions()) {
|
|
537
|
+
if (securityDecision(decision)) continue;
|
|
510
538
|
if (!unresolved(decision)) continue;
|
|
511
539
|
const context = this.store.decisionContextReason(decision);
|
|
512
540
|
if (context === "work_failed" || context === "work_cancelled") {
|
|
@@ -534,16 +562,18 @@ export class DecisionService {
|
|
|
534
562
|
}
|
|
535
563
|
}
|
|
536
564
|
for (const decision of this.store.allDecisions()) {
|
|
565
|
+
if (securityDecision(decision)) continue;
|
|
537
566
|
if (decision.method === "question" && decision.status === "pending" && decision.bindingState === "live") {
|
|
538
567
|
this.store.reconcileReissuedQuestion(decision.id, this.ports.liveBinding(decision));
|
|
539
568
|
}
|
|
540
569
|
}
|
|
570
|
+
this.store.notifySecurityReceipts();
|
|
541
571
|
const all = this.store.allDecisions();
|
|
542
572
|
for (const decision of all) {
|
|
543
573
|
const previous = this.known.get(decision.id);
|
|
544
574
|
this.known.set(decision.id, decision);
|
|
545
575
|
if (!previous || previous.revision < decision.revision) {
|
|
546
|
-
if (previous && previous.status !== decision.status && !unresolved(decision)) {
|
|
576
|
+
if (!securityDecision(decision) && previous && previous.status !== decision.status && !unresolved(decision)) {
|
|
547
577
|
const answer = record(decision.answer) ? decision.answer : {};
|
|
548
578
|
const kind = decision.method === "question" ? "question" : decision.method === "confirm" ? "confirm" : "value";
|
|
549
579
|
const dismissed = decision.status === "closed" ? DISMISSED_REASONS[decision.reason ?? ""] : undefined;
|
|
@@ -564,7 +594,7 @@ export class DecisionService {
|
|
|
564
594
|
}
|
|
565
595
|
const times: number[] = [];
|
|
566
596
|
if (unresolved(decision) && decision.bindingState === "live" && decision.deadlineAt !== null) times.push(decision.deadlineAt);
|
|
567
|
-
if (decision.status === "submitted") {
|
|
597
|
+
if (!securityDecision(decision) && decision.status === "submitted") {
|
|
568
598
|
const operation = this.store.latestDecisionOperation(decision.id);
|
|
569
599
|
times.push(Date.parse(operation?.ts ?? decision.updatedAt) + this.receiptMs);
|
|
570
600
|
}
|
|
@@ -610,7 +640,7 @@ export class DecisionService {
|
|
|
610
640
|
}
|
|
611
641
|
if (!route[2] && req.method === "GET") {
|
|
612
642
|
const decision = this.required(id);
|
|
613
|
-
if (unresolved(decision)) {
|
|
643
|
+
if (!securityDecision(decision) && unresolved(decision)) {
|
|
614
644
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
615
645
|
try {
|
|
616
646
|
await Promise.race([
|
|
@@ -630,10 +660,18 @@ export class DecisionService {
|
|
|
630
660
|
}
|
|
631
661
|
return Response.json({ error: "method_not_allowed" }, { status: 405 });
|
|
632
662
|
} catch (error) {
|
|
663
|
+
if (error instanceof SecurityCheckError) {
|
|
664
|
+
return Response.json({ error: error.code, message: error.message, held: error.held }, { status: 422, headers: { "Cache-Control": "no-store" } });
|
|
665
|
+
}
|
|
666
|
+
if (error instanceof SecurityRuntimeError) {
|
|
667
|
+
return Response.json({ error: error.code, ...(error.held ? { held: error.held } : {}) }, {
|
|
668
|
+
status: error.code === "held_conflict" ? 409 : 503, headers: { "Cache-Control": "no-store" },
|
|
669
|
+
});
|
|
670
|
+
}
|
|
633
671
|
if (error instanceof DecisionStoreError) {
|
|
634
672
|
return Response.json({
|
|
635
673
|
error: error.code, decision: error.decision ? this.info(error.decision) : null,
|
|
636
|
-
}, { status: error.code === "decision_not_found" ? 404 : error.code.startsWith("invalid_") ? 400 : 409 });
|
|
674
|
+
}, { status: error.code === "decision_not_found" ? 404 : error.code === "security_endpoint_required" || error.code.startsWith("invalid_") ? 400 : 409 });
|
|
637
675
|
}
|
|
638
676
|
if (error instanceof URIError) return Response.json({ error: "invalid_decision" }, { status: 400 });
|
|
639
677
|
this.error(error);
|
package/src/engine/compat.json
CHANGED
package/src/hub.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { scopedActionId, type DialogAnswer, type DialogGate, type DialogMethod, type DialogRequest, type DialogResolution } from "./approvals.ts";
|
|
2
2
|
import type { DecisionService } from "./decisions.ts";
|
|
3
3
|
import type { SttProvider } from "./adapters/voice/stt.ts";
|
|
4
|
-
import { OmoCommandError, type OmoEvent, type OmoStatus } from "./omo/client.ts";
|
|
4
|
+
import { blockingCompaction, compactionOutcome, OmoCommandError, type CompactionFailureKind, type OmoEvent, type OmoStatus } from "./omo/client.ts";
|
|
5
5
|
import type { OmoRpc } from "./omo/generations.ts";
|
|
6
6
|
import { type ActionAnswerData, type ConversationActivity, type ConversationInfo, type Envelope, type MessageReference, PROTOCOL_VERSION, type Source, type Trust } from "./protocol.ts";
|
|
7
7
|
import { readFileSync, statSync } from "node:fs";
|
|
@@ -13,6 +13,11 @@ 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";
|
|
17
|
+
import { SecurityRuntimeError, type SecurityReceipt } from "./security-allow.ts";
|
|
18
|
+
import { nativeReplyEntry, type NativeReplyObserver } from "./reply-notifications.ts";
|
|
19
|
+
import { notificationHistory } from "./notification-history.ts";
|
|
20
|
+
import { messageTimestamp, nativeTurnStarted, nativeTurnStats } from "./turn-stats.ts";
|
|
16
21
|
|
|
17
22
|
export type Broadcast = (envelope: Envelope) => void;
|
|
18
23
|
|
|
@@ -35,6 +40,7 @@ const HISTORY_MAX_PAGE = 500;
|
|
|
35
40
|
const HISTORY_MAX_RESUME = 1000;
|
|
36
41
|
|
|
37
42
|
export interface HubOptions {
|
|
43
|
+
replies?: NativeReplyObserver;
|
|
38
44
|
cwd: string;
|
|
39
45
|
sessionDir: string;
|
|
40
46
|
permissionPreset: string;
|
|
@@ -59,12 +65,30 @@ export interface HubOptions {
|
|
|
59
65
|
versionReport?: () => Promise<string>;
|
|
60
66
|
/** Answers `/usage` (model account usage) as a notice; the command never reaches omo. */
|
|
61
67
|
usageReport?: () => Promise<string>;
|
|
68
|
+
/** Improvement 165: the security check of user text; a finding keeps the text from omo and the store. */
|
|
69
|
+
guard?: Pick<MessageGuard, "check" | "hold" | "existing">;
|
|
62
70
|
}
|
|
63
71
|
|
|
64
72
|
/** Anthropic rejects images over 5 MB; bigger attachments reach the agent as a file path only. */
|
|
65
73
|
const PROMPT_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
|
|
66
74
|
const MAX_ATTACHMENTS = 4;
|
|
67
75
|
|
|
76
|
+
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
|
+
} as const satisfies Record<CompactionFailureKind, { readonly action: string; readonly message: string }>;
|
|
91
|
+
|
|
68
92
|
export type RotationReason = "manual" | "age" | ForcedRotationReason;
|
|
69
93
|
/** Bug 72 proposal 4: limits that rotate a session even with works, updates, decisions or questions open. */
|
|
70
94
|
export type ForcedRotationReason = "size" | "compactions";
|
|
@@ -176,6 +200,9 @@ interface ConvRuntime {
|
|
|
176
200
|
finishRotation: (() => void) | null;
|
|
177
201
|
itemId: string | null;
|
|
178
202
|
itemText: string;
|
|
203
|
+
/** Set only by the engine's structured start event; never inferred from arrival timing. */
|
|
204
|
+
statsTurnId: string | null;
|
|
205
|
+
statsMessageIndex: number;
|
|
179
206
|
}
|
|
180
207
|
|
|
181
208
|
interface ContentPart {
|
|
@@ -192,7 +219,7 @@ interface SessionEntry {
|
|
|
192
219
|
customType?: string;
|
|
193
220
|
data?: unknown;
|
|
194
221
|
details?: { notification_id?: string };
|
|
195
|
-
message?: { role?: string; content?: ContentPart[] | string; stopReason?: string; clientInputId?: string };
|
|
222
|
+
message?: { role?: string; content?: ContentPart[] | string; stopReason?: string; errorMessage?: string; clientInputId?: string };
|
|
196
223
|
}
|
|
197
224
|
|
|
198
225
|
interface EntriesResult {
|
|
@@ -342,6 +369,23 @@ export class Hub {
|
|
|
342
369
|
this.omo = omo;
|
|
343
370
|
}
|
|
344
371
|
|
|
372
|
+
/** Explicit mode activation snapshots every existing main source before admitting new occurrences. */
|
|
373
|
+
async notificationBaseline(): Promise<void> {
|
|
374
|
+
for (const conv of this.store.listConversations()) {
|
|
375
|
+
if (!conv.threadId) continue;
|
|
376
|
+
const sessionPath = conv.threadId;
|
|
377
|
+
const generation = this.generation;
|
|
378
|
+
const revision = this.options.replies?.revision();
|
|
379
|
+
const current = () => generation === this.generation &&
|
|
380
|
+
this.store.getConversation(conv.id)?.threadId === conv.threadId;
|
|
381
|
+
await notificationHistory(conv.threadId, (entry) => {
|
|
382
|
+
if (!current()) throw new Error("reply_baseline_discarded");
|
|
383
|
+
if (revision !== undefined) this.options.replies?.observe({ sessionPath, conv: conv.id, title: conv.title }, [entry], revision);
|
|
384
|
+
}, true);
|
|
385
|
+
if (!current()) throw new Error("reply_baseline_discarded");
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
345
389
|
attachDecisions(decisions: DecisionService): void {
|
|
346
390
|
this.decisions = decisions;
|
|
347
391
|
}
|
|
@@ -706,6 +750,8 @@ export class Hub {
|
|
|
706
750
|
this.uiSessionScopes.clear();
|
|
707
751
|
for (const [conv, rt] of this.runtime) {
|
|
708
752
|
rt.sessionId = null;
|
|
753
|
+
rt.statsTurnId = null;
|
|
754
|
+
rt.statsMessageIndex = 0;
|
|
709
755
|
rt.opening = null;
|
|
710
756
|
rt.running = false;
|
|
711
757
|
rt.waking = null;
|
|
@@ -757,6 +803,18 @@ export class Hub {
|
|
|
757
803
|
} else if (this.hasUnfinishedTurn(conv.id)) await this.recoverConversation(current);
|
|
758
804
|
else if (this.openQuestionCards(conv.id, this.conversationScope(conv.id)).size > 0) await this.ensureSession(current);
|
|
759
805
|
else if (this.store.unreportedWorkUpdates(conv.id).length > 0 || this.workSummary(conv.id).length > 0) await this.ensureSession(current);
|
|
806
|
+
if (this.options.replies?.settings().mode === "all-replies" && current.threadId) {
|
|
807
|
+
if (this.rt(conv.id).sessionId) await this.syncLedger(conv.id);
|
|
808
|
+
else {
|
|
809
|
+
const sessionPath = current.threadId;
|
|
810
|
+
const revision = this.options.replies.revision();
|
|
811
|
+
await notificationHistory(sessionPath, (entry) => {
|
|
812
|
+
if (generation !== this.generation || this.omoStatus !== "ready" ||
|
|
813
|
+
this.store.getConversation(conv.id)?.threadId !== sessionPath) throw new Error("reply_history_discarded");
|
|
814
|
+
this.options.replies?.observe({ sessionPath, conv: conv.id, title: current.title }, [entry], revision);
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
}
|
|
760
818
|
// Opening main comes first. A notification ACK or worker reconciliation cannot hold readiness.
|
|
761
819
|
void this.notifyWorkUpdates(conv.id);
|
|
762
820
|
} catch (error) {
|
|
@@ -770,12 +828,17 @@ export class Hub {
|
|
|
770
828
|
}
|
|
771
829
|
|
|
772
830
|
/** `images` are names of files already uploaded to the media folder; unknown names are dropped. */
|
|
773
|
-
async sendText(convId: string, text: string, src: Source, trust: Trust, images: string[] = [], referenceIds?: unknown): Promise<void> {
|
|
831
|
+
async sendText(convId: string, text: string, src: Source, trust: Trust, images: string[] = [], referenceIds?: unknown, checked = false, submissionId?: string): Promise<void> {
|
|
774
832
|
if (this.maintenanceId) throw new MaintenanceError("maintenance_held");
|
|
833
|
+
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
|
+
const context = { ...(submissionId ? { key: JSON.stringify(["message", convId, submissionId]), submissionId } : {}),
|
|
835
|
+
snapshot: structuredClone({ text, src, trust, images, referenceIds }) };
|
|
836
|
+
if (this.options.guard?.existing(context)) return;
|
|
775
837
|
const conv = this.store.getConversation(convId);
|
|
776
838
|
if (!conv) throw new Error(`unknown_conversation:${convId}`);
|
|
777
839
|
const body = text.trim();
|
|
778
|
-
const
|
|
840
|
+
const source = structuredClone(src);
|
|
841
|
+
const references = structuredClone(resolveMessageReferences(this.store, convId, referenceIds));
|
|
779
842
|
if (this.options.versionReport && images.length === 0 && references.length === 0 && isVersionCommand(body)) {
|
|
780
843
|
this.emit(convId, "notice", { kind: "version", level: "info", message: await this.options.versionReport() });
|
|
781
844
|
return;
|
|
@@ -792,22 +855,36 @@ export class Hub {
|
|
|
792
855
|
throw new MessageReferenceError("message_reference_images_unavailable");
|
|
793
856
|
}
|
|
794
857
|
if (!body && attached.length === 0 && references.length === 0) return;
|
|
795
|
-
const
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
this.
|
|
807
|
-
|
|
858
|
+
const attachmentSnapshot = this.attachments(prompt.images);
|
|
859
|
+
const send = async (receipt?: (ref: SecurityReceipt) => void) => {
|
|
860
|
+
if (receipt && prompt.images.some((name) => !dir || !mediaFile(dir, name))) throw new SecurityRuntimeError("dispatch_failed");
|
|
861
|
+
const input = this.store.acceptMainUser(convId, {
|
|
862
|
+
text: body, src: source, trust, ...(attached.length ? { images: attached } : {}),
|
|
863
|
+
...(references.length ? { references } : {}),
|
|
864
|
+
}, receipt);
|
|
865
|
+
this.publishStored(input);
|
|
866
|
+
this.decisions?.refresh();
|
|
867
|
+
const rotationDone = this.rt(convId).rotationDone;
|
|
868
|
+
if (rotationDone) await rotationDone;
|
|
869
|
+
if (this.closeUncertain(convId)) {
|
|
870
|
+
if (this.closeRecoveries.get(convId)?.exhausted) {
|
|
871
|
+
if (receipt) throw new SecurityRuntimeError("dispatch_unknown");
|
|
872
|
+
throw new Error("session_close_recovery_exhausted");
|
|
873
|
+
}
|
|
874
|
+
this.scheduleCloseRecovery(convId);
|
|
875
|
+
this.emit(convId, "notice", { kind: "session-close-recovery", level: "info",
|
|
876
|
+
message: "세션 닫기 상태를 확인하고 있습니다. 메시지는 확인 후 전달합니다." });
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
await this.prompt(convId, prompt.text, prompt.images, input.seq, true, attachmentSnapshot, receipt !== undefined);
|
|
880
|
+
};
|
|
881
|
+
// The approved closure owns the inspected references, body and image bytes. Never re-resolve reference ids.
|
|
882
|
+
const found = checked ? null : this.options.guard?.check([prompt.text]);
|
|
883
|
+
if (found) {
|
|
884
|
+
this.options.guard!.hold(convId, "message", found, send, body, context);
|
|
808
885
|
return;
|
|
809
886
|
}
|
|
810
|
-
await
|
|
887
|
+
await send();
|
|
811
888
|
}
|
|
812
889
|
|
|
813
890
|
/** Reserve gateway report admission before its HTTP body is parsed. The source
|
|
@@ -1233,6 +1310,8 @@ export class Hub {
|
|
|
1233
1310
|
rt.sessionId = null;
|
|
1234
1311
|
rt.itemId = null;
|
|
1235
1312
|
rt.itemText = "";
|
|
1313
|
+
rt.statsTurnId = null;
|
|
1314
|
+
rt.statsMessageIndex = 0;
|
|
1236
1315
|
rt.notified.clear();
|
|
1237
1316
|
const to = `${this.options.sessionDir.replace(/\/$/, "")}/zgw-${convId}-${fileStamp(this.now())}-${crypto.randomUUID().slice(0, 8)}.jsonl`;
|
|
1238
1317
|
this.store.rotateThread(convId, to, new Date(this.now()).toISOString(), retirement.handoff);
|
|
@@ -1550,7 +1629,7 @@ export class Hub {
|
|
|
1550
1629
|
}
|
|
1551
1630
|
|
|
1552
1631
|
/** Sends input once. Native same-session compaction owns overflow recovery, never gateway replay. */
|
|
1553
|
-
private async prompt(convId: string, body: string, images: string[] = [], messageSeq?: number, admitted = false): Promise<void> {
|
|
1632
|
+
private async prompt(convId: string, body: string, images: string[] = [], messageSeq?: number, admitted = false, attachmentSnapshot?: ReturnType<Hub["attachments"]>, protectedPayload = false): Promise<void> {
|
|
1554
1633
|
const conv = this.store.getConversation(convId);
|
|
1555
1634
|
if (!conv) return;
|
|
1556
1635
|
const rt = this.rt(convId);
|
|
@@ -1568,7 +1647,7 @@ export class Hub {
|
|
|
1568
1647
|
if (generation !== this.generation || this.omoStatus !== "ready" || rt.sessionId !== sessionId) return;
|
|
1569
1648
|
if (admitted && this.store.mainUserDispatched(convId, reference)) return;
|
|
1570
1649
|
const handoff = nativeCommand ? null : this.store.getSessionMeta(convId).handoff;
|
|
1571
|
-
const attached = this.attachments(images);
|
|
1650
|
+
const attached = attachmentSnapshot ?? this.attachments(images);
|
|
1572
1651
|
const text = [body, attached.note].filter(Boolean).join("\n\n");
|
|
1573
1652
|
const context = handoff ? `${handoff}\n\nZGW_WORK_STATE ${JSON.stringify(this.workSummary(convId))}` : null;
|
|
1574
1653
|
const inputId = crypto.randomUUID();
|
|
@@ -1598,6 +1677,7 @@ export class Hub {
|
|
|
1598
1677
|
this.decisions?.refresh();
|
|
1599
1678
|
} catch (error) {
|
|
1600
1679
|
if (generation !== this.generation || this.omoStatus !== "ready") return;
|
|
1680
|
+
if (protectedPayload) throw new SecurityRuntimeError("dispatch_unknown");
|
|
1601
1681
|
const message = errorMessage(error);
|
|
1602
1682
|
this.emit(convId, "error", this.continuityError(message));
|
|
1603
1683
|
}
|
|
@@ -1690,6 +1770,28 @@ export class Hub {
|
|
|
1690
1770
|
}
|
|
1691
1771
|
const rt = this.rt(conv);
|
|
1692
1772
|
switch (event.type) {
|
|
1773
|
+
case "extension_event": {
|
|
1774
|
+
if (event.name === "zgw.turn_started") {
|
|
1775
|
+
rt.statsTurnId = nativeTurnStarted(event.data);
|
|
1776
|
+
rt.statsMessageIndex = 0;
|
|
1777
|
+
} else if (event.name === "zgw.turn_stats" && rt.durableId) {
|
|
1778
|
+
const measured = nativeTurnStats(event.data);
|
|
1779
|
+
if (!measured) return;
|
|
1780
|
+
const stored = this.store.appendTurnStats(conv, rt.durableId, measured);
|
|
1781
|
+
if (stored) this.publishStored(stored);
|
|
1782
|
+
}
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1785
|
+
case "entry_appended": {
|
|
1786
|
+
// Native emits message_end before persistence; this later event closes that gap.
|
|
1787
|
+
const entry = nativeReplyEntry(event.entry);
|
|
1788
|
+
if (entry && this.options.replies) {
|
|
1789
|
+
this.options.replies.observe({ sessionPath: owner.sessionPath, conv, title: this.store.getConversation(conv)?.title ?? "Reply" },
|
|
1790
|
+
[entry], this.options.replies.revision());
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
return this.options.replies ? this.syncLedger(conv) : undefined;
|
|
1794
|
+
}
|
|
1693
1795
|
case "agent_start":
|
|
1694
1796
|
rt.run++;
|
|
1695
1797
|
rt.running = true;
|
|
@@ -1717,7 +1819,7 @@ export class Hub {
|
|
|
1717
1819
|
}
|
|
1718
1820
|
case "message_end": {
|
|
1719
1821
|
const message = event.message as {
|
|
1720
|
-
role?: string; content?: ContentPart[] | string; stopReason?: string; errorMessage?: string; clientInputId?: string;
|
|
1822
|
+
role?: string; content?: ContentPart[] | string; stopReason?: string; errorMessage?: string; clientInputId?: string; timestamp?: unknown;
|
|
1721
1823
|
customType?: string; details?: { notification_id?: string };
|
|
1722
1824
|
} | undefined;
|
|
1723
1825
|
if (message?.role === "user") {
|
|
@@ -1739,12 +1841,18 @@ export class Hub {
|
|
|
1739
1841
|
return;
|
|
1740
1842
|
}
|
|
1741
1843
|
if (message?.role !== "assistant") return;
|
|
1844
|
+
rt.statsMessageIndex++;
|
|
1742
1845
|
const itemId = rt.itemId ?? this.nextId("m");
|
|
1743
1846
|
const content = typeof message.content === "string" ? [{ type: "text", text: message.content }] : message.content ?? [];
|
|
1744
1847
|
const thinking = content.filter((c) => c.type === "thinking").map((c) => c.thinking ?? "").join("\n\n").trim();
|
|
1745
1848
|
if (thinking) this.emit(conv, "thinking.final", { itemId, text: thinking });
|
|
1746
1849
|
const text = content.filter((c) => c.type === "text").map((c) => c.text ?? "").join("");
|
|
1747
|
-
if (text || rt.itemText) this.emit(conv, "message.final", {
|
|
1850
|
+
if (text || rt.itemText) this.emit(conv, "message.final", {
|
|
1851
|
+
itemId, text: text || rt.itemText,
|
|
1852
|
+
...(rt.statsTurnId && rt.durableId && messageTimestamp(message.timestamp) &&
|
|
1853
|
+
message.stopReason !== "aborted" && message.stopReason !== "error" && !message.errorMessage
|
|
1854
|
+
? { engineTurn: { id: rt.statsTurnId, sessionId: rt.durableId, messageTimestamp: message.timestamp, messageIndex: rt.statsMessageIndex } } : {}),
|
|
1855
|
+
});
|
|
1748
1856
|
if (message.stopReason === "aborted") {
|
|
1749
1857
|
this.emit(conv, "notice", { message: "작업을 중단했습니다.", level: "info" });
|
|
1750
1858
|
} else if (message.stopReason === "error" || message.errorMessage) {
|
|
@@ -1752,7 +1860,7 @@ export class Hub {
|
|
|
1752
1860
|
}
|
|
1753
1861
|
rt.itemId = null;
|
|
1754
1862
|
rt.itemText = "";
|
|
1755
|
-
return;
|
|
1863
|
+
return this.options.replies ? this.syncLedger(conv) : undefined;
|
|
1756
1864
|
}
|
|
1757
1865
|
case "tool_execution_start":
|
|
1758
1866
|
this.emit(conv, "progress.update", {
|
|
@@ -1776,36 +1884,43 @@ export class Hub {
|
|
|
1776
1884
|
});
|
|
1777
1885
|
return;
|
|
1778
1886
|
}
|
|
1779
|
-
case "compaction_start":
|
|
1780
|
-
this.
|
|
1887
|
+
case "compaction_start": {
|
|
1888
|
+
if (!blockingCompaction(event) || this.compactionSeen(conv, event, false)) return;
|
|
1889
|
+
const requestId = typeof event.requestId === "string" ? event.requestId : undefined;
|
|
1890
|
+
this.emit(conv, "session.compaction", { status: "running", requestId });
|
|
1891
|
+
this.emit(conv, "notice", { kind: "compaction-started", level: "info", requestId,
|
|
1892
|
+
message: "대화 내용을 요약하고 있습니다. 완료될 때까지 기다려 주세요." });
|
|
1781
1893
|
return;
|
|
1894
|
+
}
|
|
1782
1895
|
case "compaction_end": {
|
|
1783
|
-
|
|
1784
|
-
|
|
1896
|
+
if (!blockingCompaction(event) || this.compactionSeen(conv, event, true)) return;
|
|
1897
|
+
const requestId = typeof event.requestId === "string" ? event.requestId : undefined;
|
|
1898
|
+
const outcome = compactionOutcome(event);
|
|
1785
1899
|
this.emit(conv, "session.compaction", {
|
|
1786
|
-
|
|
1787
|
-
reason: event.reason,
|
|
1788
|
-
requestId: event.requestId,
|
|
1789
|
-
accepted: event.accepted,
|
|
1790
|
-
error: event.errorMessage,
|
|
1791
|
-
rejectionCause: event.rejectionCause,
|
|
1900
|
+
...outcome, requestId, accepted: outcome.status === "completed",
|
|
1792
1901
|
});
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
kind: "compaction-cancelled",
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
}
|
|
1902
|
+
switch (outcome.status) {
|
|
1903
|
+
case "cancelled":
|
|
1904
|
+
this.emit(conv, "notice", { kind: "compaction-cancelled", level: "warning", requestId,
|
|
1905
|
+
message: "대화 요약을 취소했습니다. 이전 내용과 같은 세션을 유지합니다. 이전 입력은 자동으로 다시 보내지 않습니다." });
|
|
1906
|
+
break;
|
|
1907
|
+
case "failed": {
|
|
1908
|
+
const recovery = COMPACTION_RECOVERY[outcome.failureKind];
|
|
1909
|
+
this.emit(conv, "error", { kind: "compaction-failed", requestId, failureKind: outcome.failureKind,
|
|
1910
|
+
action: recovery.action, message: `대화 요약에 실패했습니다. 이전 내용은 유지했습니다. ${recovery.message}` });
|
|
1911
|
+
break;
|
|
1912
|
+
}
|
|
1913
|
+
case "completed":
|
|
1914
|
+
this.emit(conv, "notice", { kind: "compaction-completed", level: "info", requestId,
|
|
1915
|
+
message: "대화 요약을 완료했습니다. 같은 세션에서 계속합니다." });
|
|
1916
|
+
break;
|
|
1804
1917
|
}
|
|
1805
1918
|
return;
|
|
1806
1919
|
}
|
|
1807
1920
|
case "agent_settled": {
|
|
1808
1921
|
rt.running = false;
|
|
1922
|
+
rt.statsTurnId = null;
|
|
1923
|
+
rt.statsMessageIndex = 0;
|
|
1809
1924
|
this.publishActivity(conv);
|
|
1810
1925
|
this.unwatchConversation(conv);
|
|
1811
1926
|
this.dialogs.cancelScope(owner.scope, "작업이 끝났습니다.");
|
|
@@ -1828,6 +1943,8 @@ export class Hub {
|
|
|
1828
1943
|
this.releaseScopedUiOwner(owner);
|
|
1829
1944
|
rt.sessionId = null;
|
|
1830
1945
|
rt.durableId = null;
|
|
1946
|
+
rt.statsTurnId = null;
|
|
1947
|
+
rt.statsMessageIndex = 0;
|
|
1831
1948
|
rt.running = false;
|
|
1832
1949
|
this.publishActivity(conv);
|
|
1833
1950
|
rt.run++;
|
|
@@ -2230,6 +2347,7 @@ export class Hub {
|
|
|
2230
2347
|
}
|
|
2231
2348
|
|
|
2232
2349
|
private async readLedger(conv: string): Promise<void> {
|
|
2350
|
+
const replyRevision = this.options.replies?.revision();
|
|
2233
2351
|
const rt = this.rt(conv);
|
|
2234
2352
|
const generation = this.generation;
|
|
2235
2353
|
const sessionId = rt.sessionId;
|
|
@@ -2246,7 +2364,10 @@ export class Hub {
|
|
|
2246
2364
|
const result = await this.requireOmo().command<EntriesResult>("get_entries", { sessionId, since });
|
|
2247
2365
|
logLedgerRead(conv, "since", result);
|
|
2248
2366
|
if (!current()) throw new LedgerReadDiscarded();
|
|
2249
|
-
|
|
2367
|
+
const entries = ledgerEntries(result.entries);
|
|
2368
|
+
// Persist notification identity before advancing the native history cursor.
|
|
2369
|
+
if (replyRevision !== undefined) this.options.replies?.observe({ sessionPath, conv, title: this.store.getConversation(conv)?.title ?? "Reply" }, result.entries, replyRevision);
|
|
2370
|
+
this.store.appendLedger(conv, { sessionPath, durableId, lastEntryId: since }, entries, result.leafId);
|
|
2250
2371
|
return;
|
|
2251
2372
|
} catch (error) {
|
|
2252
2373
|
if (!(error instanceof OmoCommandError) || !error.code.startsWith("Entry not found")) throw error;
|
|
@@ -2256,10 +2377,13 @@ export class Hub {
|
|
|
2256
2377
|
// A full read is the one unbounded response the ledger asks for: one at a time across conversations.
|
|
2257
2378
|
const rebuild = this.ledgerRebuilds.then(async () => {
|
|
2258
2379
|
if (!current()) throw new LedgerReadDiscarded();
|
|
2380
|
+
const rebuildReplyRevision = this.options.replies?.revision();
|
|
2259
2381
|
const result = await this.requireOmo().command<EntriesResult>("get_entries", { sessionId });
|
|
2260
2382
|
logLedgerRead(conv, "full", result);
|
|
2261
2383
|
if (!current()) throw new LedgerReadDiscarded();
|
|
2262
|
-
|
|
2384
|
+
const entries = ledgerEntries(result.entries);
|
|
2385
|
+
if (rebuildReplyRevision !== undefined) this.options.replies?.observe({ sessionPath, conv, title: this.store.getConversation(conv)?.title ?? "Reply" }, result.entries, rebuildReplyRevision);
|
|
2386
|
+
this.store.rebuildLedger(conv, sessionPath, durableId, entries, result.leafId);
|
|
2263
2387
|
});
|
|
2264
2388
|
this.ledgerRebuilds = rebuild.catch(() => undefined);
|
|
2265
2389
|
await rebuild;
|
|
@@ -2361,6 +2485,7 @@ export class Hub {
|
|
|
2361
2485
|
permissionPreset: this.options.permissionPreset,
|
|
2362
2486
|
retain_on_disconnect: true,
|
|
2363
2487
|
kind: "interactive",
|
|
2488
|
+
promptSurface: "app",
|
|
2364
2489
|
context: { zgw_conv: conv.id, zgw_role: "main" },
|
|
2365
2490
|
});
|
|
2366
2491
|
return { sessionPath, opened };
|
|
@@ -2453,7 +2578,7 @@ export class Hub {
|
|
|
2453
2578
|
rt = {
|
|
2454
2579
|
sessionId: null, opening: null, running: false, run: 0, rotating: false, admitting: 0, clientInputIdentity: false, atomicQueuedResume: false, durableId: null,
|
|
2455
2580
|
waking: null, wake: null, notified: new Set(),
|
|
2456
|
-
itemId: null, itemText: "",
|
|
2581
|
+
itemId: null, itemText: "", statsTurnId: null, statsMessageIndex: 0,
|
|
2457
2582
|
rotationDone: null, finishRotation: null,
|
|
2458
2583
|
};
|
|
2459
2584
|
this.runtime.set(conv, rt);
|
|
@@ -2470,6 +2595,17 @@ export class Hub {
|
|
|
2470
2595
|
this.emit("main", "notice", { kind: "update", level: "info", message });
|
|
2471
2596
|
}
|
|
2472
2597
|
|
|
2598
|
+
/** Persisted lifecycle records also deduplicate a terminal replay after a gateway restart. */
|
|
2599
|
+
private compactionSeen(conv: string, event: OmoEvent, terminal: boolean): boolean {
|
|
2600
|
+
if (typeof event.requestId !== "string") return false;
|
|
2601
|
+
return this.store.eventsAfter(conv, this.store.lastSeq(conv, "session.rotated")).some((stored) => {
|
|
2602
|
+
if (stored.type !== "session.compaction") return false;
|
|
2603
|
+
const data = stored.data;
|
|
2604
|
+
return typeof data === "object" && data !== null && "requestId" in data && data.requestId === event.requestId &&
|
|
2605
|
+
(!terminal || ("status" in data && data.status !== "running"));
|
|
2606
|
+
});
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2473
2609
|
private emit(conv: string, type: string, data: unknown, persist = true): StoredEvent | null {
|
|
2474
2610
|
const stored = persist && conv !== "*" ? this.store.appendEvent(conv, type, data) : null;
|
|
2475
2611
|
if (stored) this.publishStored(stored);
|