zigmini 0.8.0-beta.5 → 0.8.0-beta.7
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 +78 -19
- package/README.md +79 -19
- package/assets/agent/gateway-request-rules.md +1 -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 +25 -2
- package/docs/manual.md +25 -2
- 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 +171 -8
- package/host/omo-check/patches/omo-5.1.35-phantom-attach.patch +31 -0
- 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/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 +13 -39
- package/src/cli/messages.ts +160 -0
- package/src/cli/pair.ts +32 -25
- package/src/cli/rotate-main.ts +411 -0
- package/src/cli/setup.ts +164 -75
- package/src/decisions.ts +20 -1
- package/src/hub.ts +140 -30
- package/src/main.ts +45 -4
- package/src/omo/client.ts +28 -0
- package/src/protocol.ts +33 -1
- package/src/reply-notifications.ts +144 -0
- package/src/runtime/paths.ts +2 -0
- package/src/security-check.ts +241 -0
- package/src/store.ts +71 -1
- package/src/turn-stats.ts +49 -0
- package/src/work-watch.ts +36 -10
- package/src/work.ts +29 -4
- package/web/app.js +197 -10
- package/web/decisions.js +3 -0
- package/web/gc.d.ts +2 -0
- package/web/gc.js +11 -0
- package/web/index.html +26 -10
- package/web/notifications.d.ts +25 -0
- package/web/notifications.js +118 -0
- package/web/style.css +16 -0
- package/web/tooltip.d.ts +0 -6
- package/web/tooltip.js +8 -11
- package/web/turn-stats.d.ts +26 -0
- package/web/turn-stats.js +58 -0
- package/web/update.d.ts +1 -1
- package/web/update.js +10 -3
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,9 @@ 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 { nativeReplyEntry, type NativeReplyObserver } from "./reply-notifications.ts";
|
|
18
|
+
import { messageTimestamp, nativeTurnStarted, nativeTurnStats } from "./turn-stats.ts";
|
|
16
19
|
|
|
17
20
|
export type Broadcast = (envelope: Envelope) => void;
|
|
18
21
|
|
|
@@ -35,6 +38,7 @@ const HISTORY_MAX_PAGE = 500;
|
|
|
35
38
|
const HISTORY_MAX_RESUME = 1000;
|
|
36
39
|
|
|
37
40
|
export interface HubOptions {
|
|
41
|
+
replies?: NativeReplyObserver;
|
|
38
42
|
cwd: string;
|
|
39
43
|
sessionDir: string;
|
|
40
44
|
permissionPreset: string;
|
|
@@ -59,12 +63,30 @@ export interface HubOptions {
|
|
|
59
63
|
versionReport?: () => Promise<string>;
|
|
60
64
|
/** Answers `/usage` (model account usage) as a notice; the command never reaches omo. */
|
|
61
65
|
usageReport?: () => Promise<string>;
|
|
66
|
+
/** Improvement 165: the security check of user text; a finding keeps the text from omo and the store. */
|
|
67
|
+
guard?: Pick<MessageGuard, "check" | "hold">;
|
|
62
68
|
}
|
|
63
69
|
|
|
64
70
|
/** Anthropic rejects images over 5 MB; bigger attachments reach the agent as a file path only. */
|
|
65
71
|
const PROMPT_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
|
|
66
72
|
const MAX_ATTACHMENTS = 4;
|
|
67
73
|
|
|
74
|
+
const COMPACTION_RECOVERY = {
|
|
75
|
+
invalid_model: { action: "settings", message: "요약 모델 설정을 확인하거나 compaction.model을 auto로 바꾼 뒤 다시 진행해 주세요." },
|
|
76
|
+
auth_missing: { action: "login", message: "요약에 사용하는 제공자에 로그인한 뒤 다시 진행해 주세요." },
|
|
77
|
+
auth_expired: { action: "login", message: "요약에 사용하는 제공자에 다시 로그인한 뒤 다시 진행해 주세요." },
|
|
78
|
+
quota: { action: "quota", message: "제공자의 사용 한도가 복구된 뒤 다시 진행해 주세요." },
|
|
79
|
+
org_denied: { action: "account", message: "해당 제공자의 계정과 조직 권한을 확인한 뒤 다시 진행해 주세요." },
|
|
80
|
+
model_unavailable: { action: "settings", message: "요약 모델의 사용 가능 여부와 설정을 확인한 뒤 다시 진행해 주세요." },
|
|
81
|
+
refused: { action: "retry", message: "제공자가 요약을 거절했습니다. 원인을 해결한 뒤 다시 진행해 주세요." },
|
|
82
|
+
timeout: { action: "retry", message: "요약 요청 시간이 초과됐습니다. 원인을 해결한 뒤 다시 진행해 주세요." },
|
|
83
|
+
incomplete: { action: "retry", message: "요약 응답이 완전하지 않습니다. 원인을 해결한 뒤 다시 진행해 주세요." },
|
|
84
|
+
empty: { action: "retry", message: "요약 응답이 비어 있습니다. 원인을 해결한 뒤 다시 진행해 주세요." },
|
|
85
|
+
context_budget: { action: "retry", message: "요약 결과가 대화 모델의 입력 한도에 맞지 않습니다. 원인을 해결한 뒤 다시 진행해 주세요." },
|
|
86
|
+
stale: { action: "retry", message: "요약 중 대화가 바뀌었습니다. 최신 대화에서 다시 진행해 주세요." },
|
|
87
|
+
provider: { action: "retry", message: "원인을 해결한 뒤 다시 진행해 주세요." },
|
|
88
|
+
} as const satisfies Record<CompactionFailureKind, { readonly action: string; readonly message: string }>;
|
|
89
|
+
|
|
68
90
|
export type RotationReason = "manual" | "age" | ForcedRotationReason;
|
|
69
91
|
/** Bug 72 proposal 4: limits that rotate a session even with works, updates, decisions or questions open. */
|
|
70
92
|
export type ForcedRotationReason = "size" | "compactions";
|
|
@@ -176,6 +198,9 @@ interface ConvRuntime {
|
|
|
176
198
|
finishRotation: (() => void) | null;
|
|
177
199
|
itemId: string | null;
|
|
178
200
|
itemText: string;
|
|
201
|
+
/** Set only by the engine's structured start event; never inferred from arrival timing. */
|
|
202
|
+
statsTurnId: string | null;
|
|
203
|
+
statsMessageIndex: number;
|
|
179
204
|
}
|
|
180
205
|
|
|
181
206
|
interface ContentPart {
|
|
@@ -192,7 +217,7 @@ interface SessionEntry {
|
|
|
192
217
|
customType?: string;
|
|
193
218
|
data?: unknown;
|
|
194
219
|
details?: { notification_id?: string };
|
|
195
|
-
message?: { role?: string; content?: ContentPart[] | string; stopReason?: string; clientInputId?: string };
|
|
220
|
+
message?: { role?: string; content?: ContentPart[] | string; stopReason?: string; errorMessage?: string; clientInputId?: string };
|
|
196
221
|
}
|
|
197
222
|
|
|
198
223
|
interface EntriesResult {
|
|
@@ -342,6 +367,20 @@ export class Hub {
|
|
|
342
367
|
this.omo = omo;
|
|
343
368
|
}
|
|
344
369
|
|
|
370
|
+
/** Explicit mode activation snapshots every existing main source before admitting new occurrences. */
|
|
371
|
+
async notificationBaseline(): Promise<void> {
|
|
372
|
+
for (const conv of this.store.listConversations()) {
|
|
373
|
+
if (!conv.threadId) continue;
|
|
374
|
+
const { sessionId } = await this.ensureSession(conv);
|
|
375
|
+
const generation = this.generation;
|
|
376
|
+
const revision = this.options.replies?.revision();
|
|
377
|
+
const result = await this.requireOmo().command<EntriesResult>("get_entries", { sessionId });
|
|
378
|
+
if (generation !== this.generation || this.omoStatus !== "ready" || this.rt(conv.id).sessionId !== sessionId ||
|
|
379
|
+
this.store.getConversation(conv.id)?.threadId !== conv.threadId) throw new Error("reply_baseline_discarded");
|
|
380
|
+
if (revision !== undefined) this.options.replies?.observe({ sessionPath: conv.threadId, conv: conv.id, title: conv.title }, result.entries, revision);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
345
384
|
attachDecisions(decisions: DecisionService): void {
|
|
346
385
|
this.decisions = decisions;
|
|
347
386
|
}
|
|
@@ -706,6 +745,8 @@ export class Hub {
|
|
|
706
745
|
this.uiSessionScopes.clear();
|
|
707
746
|
for (const [conv, rt] of this.runtime) {
|
|
708
747
|
rt.sessionId = null;
|
|
748
|
+
rt.statsTurnId = null;
|
|
749
|
+
rt.statsMessageIndex = 0;
|
|
709
750
|
rt.opening = null;
|
|
710
751
|
rt.running = false;
|
|
711
752
|
rt.waking = null;
|
|
@@ -757,6 +798,10 @@ export class Hub {
|
|
|
757
798
|
} else if (this.hasUnfinishedTurn(conv.id)) await this.recoverConversation(current);
|
|
758
799
|
else if (this.openQuestionCards(conv.id, this.conversationScope(conv.id)).size > 0) await this.ensureSession(current);
|
|
759
800
|
else if (this.store.unreportedWorkUpdates(conv.id).length > 0 || this.workSummary(conv.id).length > 0) await this.ensureSession(current);
|
|
801
|
+
if (this.options.replies?.settings().mode === "all-replies" && current.threadId) {
|
|
802
|
+
await this.ensureSession(this.store.getConversation(conv.id) ?? current);
|
|
803
|
+
await this.syncLedger(conv.id);
|
|
804
|
+
}
|
|
760
805
|
// Opening main comes first. A notification ACK or worker reconciliation cannot hold readiness.
|
|
761
806
|
void this.notifyWorkUpdates(conv.id);
|
|
762
807
|
} catch (error) {
|
|
@@ -770,7 +815,7 @@ export class Hub {
|
|
|
770
815
|
}
|
|
771
816
|
|
|
772
817
|
/** `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> {
|
|
818
|
+
async sendText(convId: string, text: string, src: Source, trust: Trust, images: string[] = [], referenceIds?: unknown, checked = false): Promise<void> {
|
|
774
819
|
if (this.maintenanceId) throw new MaintenanceError("maintenance_held");
|
|
775
820
|
const conv = this.store.getConversation(convId);
|
|
776
821
|
if (!conv) throw new Error(`unknown_conversation:${convId}`);
|
|
@@ -792,6 +837,13 @@ export class Hub {
|
|
|
792
837
|
throw new MessageReferenceError("message_reference_images_unavailable");
|
|
793
838
|
}
|
|
794
839
|
if (!body && attached.length === 0 && references.length === 0) return;
|
|
840
|
+
// Improvement 165: a finding holds the message in gateway memory. The guard sends the card with the values to the
|
|
841
|
+
// user's screens only (never stored, never in a prompt); the user's "send" runs this call again with checked.
|
|
842
|
+
const found = checked ? null : this.options.guard?.check([prompt.text]);
|
|
843
|
+
if (found) {
|
|
844
|
+
this.options.guard!.hold(convId, "message", found, () => this.sendText(convId, text, src, trust, images, referenceIds, true), body);
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
795
847
|
const input = this.store.acceptMainUser(convId, {
|
|
796
848
|
text: body, src, trust, ...(attached.length ? { images: attached } : {}),
|
|
797
849
|
...(references.length ? { references } : {}),
|
|
@@ -1233,6 +1285,8 @@ export class Hub {
|
|
|
1233
1285
|
rt.sessionId = null;
|
|
1234
1286
|
rt.itemId = null;
|
|
1235
1287
|
rt.itemText = "";
|
|
1288
|
+
rt.statsTurnId = null;
|
|
1289
|
+
rt.statsMessageIndex = 0;
|
|
1236
1290
|
rt.notified.clear();
|
|
1237
1291
|
const to = `${this.options.sessionDir.replace(/\/$/, "")}/zgw-${convId}-${fileStamp(this.now())}-${crypto.randomUUID().slice(0, 8)}.jsonl`;
|
|
1238
1292
|
this.store.rotateThread(convId, to, new Date(this.now()).toISOString(), retirement.handoff);
|
|
@@ -1690,6 +1744,28 @@ export class Hub {
|
|
|
1690
1744
|
}
|
|
1691
1745
|
const rt = this.rt(conv);
|
|
1692
1746
|
switch (event.type) {
|
|
1747
|
+
case "extension_event": {
|
|
1748
|
+
if (event.name === "zgw.turn_started") {
|
|
1749
|
+
rt.statsTurnId = nativeTurnStarted(event.data);
|
|
1750
|
+
rt.statsMessageIndex = 0;
|
|
1751
|
+
} else if (event.name === "zgw.turn_stats" && rt.durableId) {
|
|
1752
|
+
const measured = nativeTurnStats(event.data);
|
|
1753
|
+
if (!measured) return;
|
|
1754
|
+
const stored = this.store.appendTurnStats(conv, rt.durableId, measured);
|
|
1755
|
+
if (stored) this.publishStored(stored);
|
|
1756
|
+
}
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
case "entry_appended": {
|
|
1760
|
+
// Native emits message_end before persistence; this later event closes that gap.
|
|
1761
|
+
const entry = nativeReplyEntry(event.entry);
|
|
1762
|
+
if (entry && this.options.replies) {
|
|
1763
|
+
this.options.replies.observe({ sessionPath: owner.sessionPath, conv, title: this.store.getConversation(conv)?.title ?? "Reply" },
|
|
1764
|
+
[entry], this.options.replies.revision());
|
|
1765
|
+
return;
|
|
1766
|
+
}
|
|
1767
|
+
return this.options.replies ? this.syncLedger(conv) : undefined;
|
|
1768
|
+
}
|
|
1693
1769
|
case "agent_start":
|
|
1694
1770
|
rt.run++;
|
|
1695
1771
|
rt.running = true;
|
|
@@ -1717,7 +1793,7 @@ export class Hub {
|
|
|
1717
1793
|
}
|
|
1718
1794
|
case "message_end": {
|
|
1719
1795
|
const message = event.message as {
|
|
1720
|
-
role?: string; content?: ContentPart[] | string; stopReason?: string; errorMessage?: string; clientInputId?: string;
|
|
1796
|
+
role?: string; content?: ContentPart[] | string; stopReason?: string; errorMessage?: string; clientInputId?: string; timestamp?: unknown;
|
|
1721
1797
|
customType?: string; details?: { notification_id?: string };
|
|
1722
1798
|
} | undefined;
|
|
1723
1799
|
if (message?.role === "user") {
|
|
@@ -1739,12 +1815,18 @@ export class Hub {
|
|
|
1739
1815
|
return;
|
|
1740
1816
|
}
|
|
1741
1817
|
if (message?.role !== "assistant") return;
|
|
1818
|
+
rt.statsMessageIndex++;
|
|
1742
1819
|
const itemId = rt.itemId ?? this.nextId("m");
|
|
1743
1820
|
const content = typeof message.content === "string" ? [{ type: "text", text: message.content }] : message.content ?? [];
|
|
1744
1821
|
const thinking = content.filter((c) => c.type === "thinking").map((c) => c.thinking ?? "").join("\n\n").trim();
|
|
1745
1822
|
if (thinking) this.emit(conv, "thinking.final", { itemId, text: thinking });
|
|
1746
1823
|
const text = content.filter((c) => c.type === "text").map((c) => c.text ?? "").join("");
|
|
1747
|
-
if (text || rt.itemText) this.emit(conv, "message.final", {
|
|
1824
|
+
if (text || rt.itemText) this.emit(conv, "message.final", {
|
|
1825
|
+
itemId, text: text || rt.itemText,
|
|
1826
|
+
...(rt.statsTurnId && rt.durableId && messageTimestamp(message.timestamp) &&
|
|
1827
|
+
message.stopReason !== "aborted" && message.stopReason !== "error" && !message.errorMessage
|
|
1828
|
+
? { engineTurn: { id: rt.statsTurnId, sessionId: rt.durableId, messageTimestamp: message.timestamp, messageIndex: rt.statsMessageIndex } } : {}),
|
|
1829
|
+
});
|
|
1748
1830
|
if (message.stopReason === "aborted") {
|
|
1749
1831
|
this.emit(conv, "notice", { message: "작업을 중단했습니다.", level: "info" });
|
|
1750
1832
|
} else if (message.stopReason === "error" || message.errorMessage) {
|
|
@@ -1752,7 +1834,7 @@ export class Hub {
|
|
|
1752
1834
|
}
|
|
1753
1835
|
rt.itemId = null;
|
|
1754
1836
|
rt.itemText = "";
|
|
1755
|
-
return;
|
|
1837
|
+
return this.options.replies ? this.syncLedger(conv) : undefined;
|
|
1756
1838
|
}
|
|
1757
1839
|
case "tool_execution_start":
|
|
1758
1840
|
this.emit(conv, "progress.update", {
|
|
@@ -1776,36 +1858,43 @@ export class Hub {
|
|
|
1776
1858
|
});
|
|
1777
1859
|
return;
|
|
1778
1860
|
}
|
|
1779
|
-
case "compaction_start":
|
|
1780
|
-
this.
|
|
1861
|
+
case "compaction_start": {
|
|
1862
|
+
if (!blockingCompaction(event) || this.compactionSeen(conv, event, false)) return;
|
|
1863
|
+
const requestId = typeof event.requestId === "string" ? event.requestId : undefined;
|
|
1864
|
+
this.emit(conv, "session.compaction", { status: "running", requestId });
|
|
1865
|
+
this.emit(conv, "notice", { kind: "compaction-started", level: "info", requestId,
|
|
1866
|
+
message: "대화 내용을 요약하고 있습니다. 완료될 때까지 기다려 주세요." });
|
|
1781
1867
|
return;
|
|
1868
|
+
}
|
|
1782
1869
|
case "compaction_end": {
|
|
1783
|
-
|
|
1784
|
-
|
|
1870
|
+
if (!blockingCompaction(event) || this.compactionSeen(conv, event, true)) return;
|
|
1871
|
+
const requestId = typeof event.requestId === "string" ? event.requestId : undefined;
|
|
1872
|
+
const outcome = compactionOutcome(event);
|
|
1785
1873
|
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,
|
|
1874
|
+
...outcome, requestId, accepted: outcome.status === "completed",
|
|
1792
1875
|
});
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
kind: "compaction-cancelled",
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
}
|
|
1876
|
+
switch (outcome.status) {
|
|
1877
|
+
case "cancelled":
|
|
1878
|
+
this.emit(conv, "notice", { kind: "compaction-cancelled", level: "warning", requestId,
|
|
1879
|
+
message: "대화 요약을 취소했습니다. 이전 내용과 같은 세션을 유지합니다. 이전 입력은 자동으로 다시 보내지 않습니다." });
|
|
1880
|
+
break;
|
|
1881
|
+
case "failed": {
|
|
1882
|
+
const recovery = COMPACTION_RECOVERY[outcome.failureKind];
|
|
1883
|
+
this.emit(conv, "error", { kind: "compaction-failed", requestId, failureKind: outcome.failureKind,
|
|
1884
|
+
action: recovery.action, message: `대화 요약에 실패했습니다. 이전 내용은 유지했습니다. ${recovery.message}` });
|
|
1885
|
+
break;
|
|
1886
|
+
}
|
|
1887
|
+
case "completed":
|
|
1888
|
+
this.emit(conv, "notice", { kind: "compaction-completed", level: "info", requestId,
|
|
1889
|
+
message: "대화 요약을 완료했습니다. 같은 세션에서 계속합니다." });
|
|
1890
|
+
break;
|
|
1804
1891
|
}
|
|
1805
1892
|
return;
|
|
1806
1893
|
}
|
|
1807
1894
|
case "agent_settled": {
|
|
1808
1895
|
rt.running = false;
|
|
1896
|
+
rt.statsTurnId = null;
|
|
1897
|
+
rt.statsMessageIndex = 0;
|
|
1809
1898
|
this.publishActivity(conv);
|
|
1810
1899
|
this.unwatchConversation(conv);
|
|
1811
1900
|
this.dialogs.cancelScope(owner.scope, "작업이 끝났습니다.");
|
|
@@ -1828,6 +1917,8 @@ export class Hub {
|
|
|
1828
1917
|
this.releaseScopedUiOwner(owner);
|
|
1829
1918
|
rt.sessionId = null;
|
|
1830
1919
|
rt.durableId = null;
|
|
1920
|
+
rt.statsTurnId = null;
|
|
1921
|
+
rt.statsMessageIndex = 0;
|
|
1831
1922
|
rt.running = false;
|
|
1832
1923
|
this.publishActivity(conv);
|
|
1833
1924
|
rt.run++;
|
|
@@ -2230,6 +2321,7 @@ export class Hub {
|
|
|
2230
2321
|
}
|
|
2231
2322
|
|
|
2232
2323
|
private async readLedger(conv: string): Promise<void> {
|
|
2324
|
+
const replyRevision = this.options.replies?.revision();
|
|
2233
2325
|
const rt = this.rt(conv);
|
|
2234
2326
|
const generation = this.generation;
|
|
2235
2327
|
const sessionId = rt.sessionId;
|
|
@@ -2246,7 +2338,10 @@ export class Hub {
|
|
|
2246
2338
|
const result = await this.requireOmo().command<EntriesResult>("get_entries", { sessionId, since });
|
|
2247
2339
|
logLedgerRead(conv, "since", result);
|
|
2248
2340
|
if (!current()) throw new LedgerReadDiscarded();
|
|
2249
|
-
|
|
2341
|
+
const entries = ledgerEntries(result.entries);
|
|
2342
|
+
// Persist notification identity before advancing the native history cursor.
|
|
2343
|
+
if (replyRevision !== undefined) this.options.replies?.observe({ sessionPath, conv, title: this.store.getConversation(conv)?.title ?? "Reply" }, result.entries, replyRevision);
|
|
2344
|
+
this.store.appendLedger(conv, { sessionPath, durableId, lastEntryId: since }, entries, result.leafId);
|
|
2250
2345
|
return;
|
|
2251
2346
|
} catch (error) {
|
|
2252
2347
|
if (!(error instanceof OmoCommandError) || !error.code.startsWith("Entry not found")) throw error;
|
|
@@ -2256,10 +2351,13 @@ export class Hub {
|
|
|
2256
2351
|
// A full read is the one unbounded response the ledger asks for: one at a time across conversations.
|
|
2257
2352
|
const rebuild = this.ledgerRebuilds.then(async () => {
|
|
2258
2353
|
if (!current()) throw new LedgerReadDiscarded();
|
|
2354
|
+
const rebuildReplyRevision = this.options.replies?.revision();
|
|
2259
2355
|
const result = await this.requireOmo().command<EntriesResult>("get_entries", { sessionId });
|
|
2260
2356
|
logLedgerRead(conv, "full", result);
|
|
2261
2357
|
if (!current()) throw new LedgerReadDiscarded();
|
|
2262
|
-
|
|
2358
|
+
const entries = ledgerEntries(result.entries);
|
|
2359
|
+
if (rebuildReplyRevision !== undefined) this.options.replies?.observe({ sessionPath, conv, title: this.store.getConversation(conv)?.title ?? "Reply" }, result.entries, rebuildReplyRevision);
|
|
2360
|
+
this.store.rebuildLedger(conv, sessionPath, durableId, entries, result.leafId);
|
|
2263
2361
|
});
|
|
2264
2362
|
this.ledgerRebuilds = rebuild.catch(() => undefined);
|
|
2265
2363
|
await rebuild;
|
|
@@ -2361,6 +2459,7 @@ export class Hub {
|
|
|
2361
2459
|
permissionPreset: this.options.permissionPreset,
|
|
2362
2460
|
retain_on_disconnect: true,
|
|
2363
2461
|
kind: "interactive",
|
|
2462
|
+
promptSurface: "app",
|
|
2364
2463
|
context: { zgw_conv: conv.id, zgw_role: "main" },
|
|
2365
2464
|
});
|
|
2366
2465
|
return { sessionPath, opened };
|
|
@@ -2453,7 +2552,7 @@ export class Hub {
|
|
|
2453
2552
|
rt = {
|
|
2454
2553
|
sessionId: null, opening: null, running: false, run: 0, rotating: false, admitting: 0, clientInputIdentity: false, atomicQueuedResume: false, durableId: null,
|
|
2455
2554
|
waking: null, wake: null, notified: new Set(),
|
|
2456
|
-
itemId: null, itemText: "",
|
|
2555
|
+
itemId: null, itemText: "", statsTurnId: null, statsMessageIndex: 0,
|
|
2457
2556
|
rotationDone: null, finishRotation: null,
|
|
2458
2557
|
};
|
|
2459
2558
|
this.runtime.set(conv, rt);
|
|
@@ -2470,6 +2569,17 @@ export class Hub {
|
|
|
2470
2569
|
this.emit("main", "notice", { kind: "update", level: "info", message });
|
|
2471
2570
|
}
|
|
2472
2571
|
|
|
2572
|
+
/** Persisted lifecycle records also deduplicate a terminal replay after a gateway restart. */
|
|
2573
|
+
private compactionSeen(conv: string, event: OmoEvent, terminal: boolean): boolean {
|
|
2574
|
+
if (typeof event.requestId !== "string") return false;
|
|
2575
|
+
return this.store.eventsAfter(conv, this.store.lastSeq(conv, "session.rotated")).some((stored) => {
|
|
2576
|
+
if (stored.type !== "session.compaction") return false;
|
|
2577
|
+
const data = stored.data;
|
|
2578
|
+
return typeof data === "object" && data !== null && "requestId" in data && data.requestId === event.requestId &&
|
|
2579
|
+
(!terminal || ("status" in data && data.status !== "running"));
|
|
2580
|
+
});
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2473
2583
|
private emit(conv: string, type: string, data: unknown, persist = true): StoredEvent | null {
|
|
2474
2584
|
const stored = persist && conv !== "*" ? this.store.appendEvent(conv, type, data) : null;
|
|
2475
2585
|
if (stored) this.publishStored(stored);
|
package/src/main.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { join, normalize } from "node:path";
|
|
|
4
4
|
import type { Server, ServerWebSocket, WebSocketHandler } from "bun";
|
|
5
5
|
import { createSttProvider } from "./adapters/voice/stt.ts";
|
|
6
6
|
import { createTtsProvider, speakableText, splitForTts } from "./adapters/voice/tts.ts";
|
|
7
|
+
import { handleHeld, handleSecurityCheckSetting, MessageGuard, SECURITY_CHECK_PATH, SECURITY_HELD_PATH, SecurityCheckError, defaultChecker } from "./security-check.ts";
|
|
7
8
|
import { handleVoiceInstall, handleVoiceSetting, routeVoiceApi, VOICE_INSTALL_PATH, VOICE_SETTING_PATH, voiceGate, voiceUpdated } from "./voice-api.ts";
|
|
8
9
|
import { readVoiceSetting, VoiceSetting } from "./voice-setting.ts";
|
|
9
10
|
import { applyVoiceAgents } from "./voice/agents.ts";
|
|
@@ -44,8 +45,9 @@ import { activeRecord, readRegistry, slotUrl } from "./runtime/slots.ts";
|
|
|
44
45
|
import { NATIVE_LIFECYCLE_PREFIX } from "./omo/lifecycle.ts";
|
|
45
46
|
import { RECOVERY_RECEIPT_PREFIX } from "./omo/recovery-receipt.ts";
|
|
46
47
|
import { fileScheduleSource, notionRoutineSource, ReminderLoop } from "./schedule/reminders.ts";
|
|
47
|
-
import { type ClientMessage, type Envelope, PROTOCOL_VERSION, type Source } from "./protocol.ts";
|
|
48
|
+
import { type ClientMessage, type Envelope, PROTOCOL_VERSION, ROTATION_HELD_MESSAGE, type Source } from "./protocol.ts";
|
|
48
49
|
import { QuestionNotifications } from "./question-notifications.ts";
|
|
50
|
+
import { NotificationSettingsApi, ReplyNotifications } from "./reply-notifications.ts";
|
|
49
51
|
import { DecisionStoreError, Store } from "./store.ts";
|
|
50
52
|
import { TranscriptLog } from "./transcript.ts";
|
|
51
53
|
import { WorkRouter } from "./work.ts";
|
|
@@ -145,11 +147,18 @@ const updateApi: UpdateApiDeps = {
|
|
|
145
147
|
};
|
|
146
148
|
|
|
147
149
|
let publish: (envelope: Envelope) => void = () => {};
|
|
150
|
+
const replyNotifications = new ReplyNotifications<ServerWebSocket<Conn>>(store, (ws, notification) => {
|
|
151
|
+
if (ws.data.deviceId && store.getDevice(ws.data.deviceId)?.status === "approved") ws.send(envelope("reply.notification", notification, notification.conv));
|
|
152
|
+
});
|
|
153
|
+
const messageGuard = new MessageGuard(defaultChecker(), runtimePaths().securityCheckFile, (held) => publish({
|
|
154
|
+
v: PROTOCOL_VERSION, id: crypto.randomUUID(), ts: new Date().toISOString(), conv: held.conv, type: "security-check.held", data: held,
|
|
155
|
+
}));
|
|
148
156
|
const hub = new Hub(
|
|
149
157
|
store,
|
|
150
158
|
dialogs,
|
|
151
159
|
stt,
|
|
152
160
|
{
|
|
161
|
+
replies: replyNotifications,
|
|
153
162
|
cwd: config.omoCwd,
|
|
154
163
|
sessionDir: config.omoSessionDir,
|
|
155
164
|
permissionPreset: config.omoPermissionPreset,
|
|
@@ -164,6 +173,7 @@ const hub = new Hub(
|
|
|
164
173
|
build: process.env.ZGW_BUILD_SHA,
|
|
165
174
|
}),
|
|
166
175
|
usageReport: createUsageReport({ dir: agentDir(), inContainer: existsSync("/.dockerenv") }),
|
|
176
|
+
guard: messageGuard,
|
|
167
177
|
},
|
|
168
178
|
(e) => publish(e),
|
|
169
179
|
);
|
|
@@ -205,7 +215,7 @@ const omo = new GenerationRouter(startup.endpoints, startup.active, {
|
|
|
205
215
|
onEvent: (e) => {
|
|
206
216
|
if (work.onEvent(e)) return;
|
|
207
217
|
summarizer.onEvent(e, hub.ownsSession(e.sessionId));
|
|
208
|
-
hub.onEvent(e);
|
|
218
|
+
void Promise.resolve(hub.onEvent(e)).catch((error) => console.error("native event:", error));
|
|
209
219
|
},
|
|
210
220
|
onStatus: (s, d) => {
|
|
211
221
|
const generation = ++connectionGeneration;
|
|
@@ -249,10 +259,12 @@ function releaseRecovery(): void {
|
|
|
249
259
|
})().finally(() => { releaseRunning = null; });
|
|
250
260
|
}
|
|
251
261
|
const work = new WorkRouter(store, omo, {
|
|
262
|
+
replies: replyNotifications,
|
|
252
263
|
sessionDir: config.omoSessionDir,
|
|
253
264
|
permissionPreset: config.omoPermissionPreset,
|
|
254
265
|
onPublished(event) {
|
|
255
266
|
hub.publishStored(event);
|
|
267
|
+
replyNotifications.deliver();
|
|
256
268
|
// A published work report raises that work's `reported` in the work status.
|
|
257
269
|
workStatus.schedule();
|
|
258
270
|
},
|
|
@@ -285,6 +297,12 @@ const work = new WorkRouter(store, omo, {
|
|
|
285
297
|
else hub.handleScopedUiRequest(owner, event);
|
|
286
298
|
},
|
|
287
299
|
});
|
|
300
|
+
const notificationSettingsApi = new NotificationSettingsApi(replyNotifications, async () => {
|
|
301
|
+
await hub.notificationBaseline();
|
|
302
|
+
await work.notificationBaseline();
|
|
303
|
+
}, (data) => publish({
|
|
304
|
+
v: PROTOCOL_VERSION, id: crypto.randomUUID(), ts: new Date().toISOString(), conv: "*", type: "notifications.updated", data,
|
|
305
|
+
}));
|
|
288
306
|
hub.attachOmo(omo);
|
|
289
307
|
const omoCheck = new OmoCheckScheduler(store, {
|
|
290
308
|
cwd: process.env.ZGW_OMO_CHECK_CWD ?? "",
|
|
@@ -302,7 +320,7 @@ const decisions = new DecisionService(store, {
|
|
|
302
320
|
}, (data) => publish({
|
|
303
321
|
v: PROTOCOL_VERSION, id: crypto.randomUUID(), ts: new Date().toISOString(),
|
|
304
322
|
conv: data.decision.ownerConv, type: "decision.updated", data,
|
|
305
|
-
}), { onHistory: (event) => hub.publishStored(event) });
|
|
323
|
+
}), { onHistory: (event) => hub.publishStored(event), guard: messageGuard });
|
|
306
324
|
hub.attachDecisions(decisions);
|
|
307
325
|
const summarizer = new SpeechSummarizer(omo, {
|
|
308
326
|
sessionDir: config.omoSessionDir,
|
|
@@ -373,6 +391,7 @@ async function handleClient(ws: ServerWebSocket<Conn>, raw: string): Promise<voi
|
|
|
373
391
|
ws.data.notifyOnly &&
|
|
374
392
|
msg.type !== "hello" &&
|
|
375
393
|
msg.type !== "reminder.ack" &&
|
|
394
|
+
msg.type !== "reply.notification.ack" &&
|
|
376
395
|
msg.type !== "question.notification.ack"
|
|
377
396
|
) return;
|
|
378
397
|
if (
|
|
@@ -403,6 +422,7 @@ async function handleClient(ws: ServerWebSocket<Conn>, raw: string): Promise<voi
|
|
|
403
422
|
if (state.omo === "ready") questionNotifications.sync(snapshot.approvals);
|
|
404
423
|
if (ws.data.notifyOnly) {
|
|
405
424
|
if (ws.data.deviceId) questionNotifications.connectDevice(ws, ws.data.deviceId);
|
|
425
|
+
if (ws.data.deviceId) replyNotifications.connectDevice(ws, ws.data.deviceId);
|
|
406
426
|
for (const r of reminders.pending(Date.now())) ws.send(envelope("reminder", r));
|
|
407
427
|
return;
|
|
408
428
|
}
|
|
@@ -423,6 +443,10 @@ async function handleClient(ws: ServerWebSocket<Conn>, raw: string): Promise<voi
|
|
|
423
443
|
case "reminder.ack":
|
|
424
444
|
if (typeof msg.data?.id === "string") reminders.ack(msg.data.id, Date.now());
|
|
425
445
|
return;
|
|
446
|
+
case "reply.notification.ack":
|
|
447
|
+
if (ws.data.notifyOnly && ws.data.deviceId && typeof msg.data?.id === "string" &&
|
|
448
|
+
store.getDevice(ws.data.deviceId)?.status === "approved") replyNotifications.ack(ws, msg.data.id);
|
|
449
|
+
return;
|
|
426
450
|
case "question.notification.ack": {
|
|
427
451
|
const id = ws.data.deviceId;
|
|
428
452
|
if (
|
|
@@ -461,6 +485,9 @@ async function handleClient(ws: ServerWebSocket<Conn>, raw: string): Promise<voi
|
|
|
461
485
|
ws.send(envelope("error", { message: "approval_not_pending", actionId: msg.data.actionId }, msg.conv));
|
|
462
486
|
}
|
|
463
487
|
} catch (error) {
|
|
488
|
+
if (error instanceof SecurityCheckError) {
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
464
491
|
if (!(error instanceof DecisionStoreError)) throw error;
|
|
465
492
|
ws.send(envelope("error", {
|
|
466
493
|
message: error.code, actionId: msg.data.actionId,
|
|
@@ -475,7 +502,7 @@ async function handleClient(ws: ServerWebSocket<Conn>, raw: string): Promise<voi
|
|
|
475
502
|
return;
|
|
476
503
|
case "conversation.rotate":
|
|
477
504
|
await hub.rotateSession(msg.conv, "manual").catch((e) => {
|
|
478
|
-
const message = e instanceof Error && e.message === "turn_running" ?
|
|
505
|
+
const message = e instanceof Error && e.message === "turn_running" ? ROTATION_HELD_MESSAGE : String(e);
|
|
479
506
|
ws.send(envelope("error", { message }, msg.conv));
|
|
480
507
|
});
|
|
481
508
|
return;
|
|
@@ -610,6 +637,7 @@ async function handle(req: Request, srv: Server<Conn>, trusted: boolean): Promis
|
|
|
610
637
|
return srv.upgrade(req, { data: { trusted, deviceId, notifyOnly, webClient: false } }) ? undefined : new Response("upgrade failed", { status: 400 });
|
|
611
638
|
}
|
|
612
639
|
// Voice off answers 503 before native admission and before any body is read.
|
|
640
|
+
if (url.pathname === "/api/settings/notifications") return notificationSettingsApi.handle(req);
|
|
613
641
|
const voiceDisabled = voiceGate(req, url, voiceSetting);
|
|
614
642
|
if (voiceDisabled) return voiceDisabled;
|
|
615
643
|
if (url.pathname === VOICE_INSTALL_PATH) {
|
|
@@ -623,6 +651,18 @@ async function handle(req: Request, srv: Server<Conn>, trusted: boolean): Promis
|
|
|
623
651
|
});
|
|
624
652
|
if (response) return response;
|
|
625
653
|
}
|
|
654
|
+
if (url.pathname === SECURITY_HELD_PATH || url.pathname.startsWith(`${SECURITY_HELD_PATH}/`)) {
|
|
655
|
+
const response = await handleHeld(req, url, messageGuard, (id, outcome) => publish({
|
|
656
|
+
v: PROTOCOL_VERSION, id: crypto.randomUUID(), ts: new Date().toISOString(), conv: "*", type: "security-check.resolved", data: { id, outcome },
|
|
657
|
+
}));
|
|
658
|
+
if (response) return response;
|
|
659
|
+
}
|
|
660
|
+
if (url.pathname === SECURITY_CHECK_PATH) {
|
|
661
|
+
const response = await handleSecurityCheckSetting(req, trusted, messageGuard, (enabled) => publish({
|
|
662
|
+
v: PROTOCOL_VERSION, id: crypto.randomUUID(), ts: new Date().toISOString(), conv: "*", type: "security-check.updated", data: { enabled },
|
|
663
|
+
}));
|
|
664
|
+
if (response) return response;
|
|
665
|
+
}
|
|
626
666
|
if (url.pathname === PROFILE_ICON_PATH) {
|
|
627
667
|
const response = await handleProfileIcon(req, url, trusted, { icon: profileIcon, profile: () => readProfile(store), publish: (e) => publish(e) });
|
|
628
668
|
if (response) return response;
|
|
@@ -827,6 +867,7 @@ const websocket: WebSocketHandler<Conn> = {
|
|
|
827
867
|
close(ws) {
|
|
828
868
|
if (ws.data.deviceId) deviceSockets.get(ws.data.deviceId)?.delete(ws);
|
|
829
869
|
questionNotifications.disconnectDevice(ws);
|
|
870
|
+
replyNotifications.disconnectDevice(ws);
|
|
830
871
|
questionNotifications.disconnectWeb(ws);
|
|
831
872
|
},
|
|
832
873
|
};
|
package/src/omo/client.ts
CHANGED
|
@@ -10,6 +10,34 @@ export interface OmoEvent {
|
|
|
10
10
|
[key: string]: unknown;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/** Public S3 terminal categories. Native diagnostics never cross the UI boundary. */
|
|
14
|
+
export const COMPACTION_FAILURE_KINDS = [
|
|
15
|
+
"invalid_model", "auth_missing", "auth_expired", "quota", "org_denied", "model_unavailable",
|
|
16
|
+
"refused", "timeout", "incomplete", "empty", "context_budget", "stale", "provider",
|
|
17
|
+
] as const;
|
|
18
|
+
export type CompactionFailureKind = typeof COMPACTION_FAILURE_KINDS[number];
|
|
19
|
+
export type CompactionOutcome =
|
|
20
|
+
| { readonly status: "failed"; readonly failureKind: CompactionFailureKind }
|
|
21
|
+
| { readonly status: "cancelled" | "completed" };
|
|
22
|
+
|
|
23
|
+
export function blockingCompaction(event: OmoEvent): boolean {
|
|
24
|
+
return event.blocking !== false && event.reason !== "speculative" && event.reason !== "idle";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Old engines have no structured failure; do not classify their raw error strings. */
|
|
28
|
+
export function compactionOutcome(event: OmoEvent): CompactionOutcome {
|
|
29
|
+
const failure = event.failure;
|
|
30
|
+
const structured = typeof failure === "object" && failure !== null;
|
|
31
|
+
if (event.aborted === true && !structured && event.rejectionCause !== "cancelled-by-extension") return { status: "cancelled" };
|
|
32
|
+
const result = typeof event.result === "object" && event.result !== null && !Array.isArray(event.result);
|
|
33
|
+
const failed = structured || typeof event.errorMessage === "string" || event.accepted === false ||
|
|
34
|
+
(event.accepted !== true && !result);
|
|
35
|
+
if (!failed) return { status: "completed" };
|
|
36
|
+
const kind = structured && "kind" in failure ? failure.kind : undefined;
|
|
37
|
+
for (const known of COMPACTION_FAILURE_KINDS) if (kind === known) return { status: "failed", failureKind: known };
|
|
38
|
+
return { status: "failed", failureKind: "provider" };
|
|
39
|
+
}
|
|
40
|
+
|
|
13
41
|
export interface OmoResponse {
|
|
14
42
|
type: "response";
|
|
15
43
|
id: string;
|
package/src/protocol.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
export const PROTOCOL_VERSION = 1;
|
|
2
2
|
|
|
3
|
+
/** The `error` message a refused `conversation.rotate` gets when a turn or an open wait holds the session (hub `turn_running`). */
|
|
4
|
+
export const ROTATION_HELD_MESSAGE = "진행 중이거나 확인되지 않은 작업이 있어 같은 세션을 유지합니다.";
|
|
5
|
+
|
|
3
6
|
export type Channel = "web" | "app" | "home" | "hud" | "discord";
|
|
4
7
|
export type Trust = "owner" | "owner-voice" | "unknown";
|
|
5
8
|
|
|
@@ -16,6 +19,22 @@ export interface MessageReference {
|
|
|
16
19
|
readonly images?: readonly string[];
|
|
17
20
|
}
|
|
18
21
|
|
|
22
|
+
/** Measured across one native response turn, including its model calls around tools. */
|
|
23
|
+
export interface TurnStats {
|
|
24
|
+
readonly tokensPerSecond?: number;
|
|
25
|
+
/** Sum of assistant/model processing intervals, not tool or total wall time. */
|
|
26
|
+
readonly modelDurationMs?: number;
|
|
27
|
+
/** Missing cache usage is unknown, not zero. */
|
|
28
|
+
readonly cacheHitPercent?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface MessageStatsData {
|
|
32
|
+
readonly itemId: string;
|
|
33
|
+
readonly messageSeq: number;
|
|
34
|
+
readonly turnId: string;
|
|
35
|
+
readonly stats: TurnStats;
|
|
36
|
+
}
|
|
37
|
+
|
|
19
38
|
export interface Envelope<T extends string = string, D = unknown> {
|
|
20
39
|
v: typeof PROTOCOL_VERSION;
|
|
21
40
|
id: string;
|
|
@@ -115,6 +134,18 @@ export interface DecisionUpdatedData {
|
|
|
115
134
|
readonly counters: DecisionCountersInfo;
|
|
116
135
|
}
|
|
117
136
|
|
|
137
|
+
export type NotificationMode = "inbox-only" | "all-replies";
|
|
138
|
+
export interface NotificationSettings {
|
|
139
|
+
readonly mode: NotificationMode;
|
|
140
|
+
}
|
|
141
|
+
export interface ReplyNotification {
|
|
142
|
+
readonly id: string;
|
|
143
|
+
readonly conv: string;
|
|
144
|
+
readonly title: string;
|
|
145
|
+
readonly text: string;
|
|
146
|
+
readonly workId?: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
118
149
|
export type ClientMessage =
|
|
119
150
|
| { type: "hello"; data: { client: string; version: string } }
|
|
120
151
|
| { type: "client.presence"; data: { visible: boolean } }
|
|
@@ -125,4 +156,5 @@ export type ClientMessage =
|
|
|
125
156
|
| { type: "input.interrupt"; conv: string; data: Record<string, never> }
|
|
126
157
|
| { type: "conversation.rotate"; conv: string }
|
|
127
158
|
| { type: "reminder.ack"; data: { id: string } }
|
|
128
|
-
| { type: "question.notification.ack"; data: { id: string } }
|
|
159
|
+
| { type: "question.notification.ack"; data: { id: string } }
|
|
160
|
+
| { type: "reply.notification.ack"; data: { id: string } };
|