zigmini 0.8.0-beta.6 → 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.
Files changed (49) hide show
  1. package/README.ko.md +77 -19
  2. package/README.md +78 -19
  3. package/assets/agent/gateway-request-rules.md +1 -0
  4. package/assets/agent/gateway-rules.md +6 -0
  5. package/assets/android/zigmini.apk +0 -0
  6. package/assets/android/zigmini.apk.json +3 -3
  7. package/assets/build.json +1 -1
  8. package/docs/manual.ko.md +23 -1
  9. package/docs/manual.md +23 -1
  10. package/host/omo-bridge/task-control.ts +4 -0
  11. package/host/omo-check/patches/omo-5.1.35-compaction-idle.patch +77 -0
  12. package/host/omo-check/patches/omo-5.1.35-compaction-intent.patch +212 -0
  13. package/host/omo-check/patches/omo-5.1.35-compaction-model.patch +299 -0
  14. package/host/omo-check/patches/omo-5.1.35-compaction-preservation.patch +1063 -0
  15. package/host/omo-check/patches/omo-5.1.35-compaction-refusal.patch +83 -0
  16. package/host/omo-check/patches/omo-5.1.35-gateway-port.manifest.json +169 -7
  17. package/host/omo-check/patches/omo-5.1.35-request-rules.patch +109 -0
  18. package/host/omo-check/patches/omo-5.1.35-turn-stats.patch +76 -0
  19. package/package.json +4 -1
  20. package/scripts/secret-rules.ts +101 -0
  21. package/scripts/secret-scan.allow.json +155 -0
  22. package/scripts/secret-scan.rules.json +30 -0
  23. package/src/agent-request-rules.ts +82 -0
  24. package/src/cli/access.ts +31 -34
  25. package/src/cli/launchd.ts +30 -4
  26. package/src/cli/main.ts +3 -43
  27. package/src/cli/messages.ts +160 -0
  28. package/src/cli/pair.ts +32 -25
  29. package/src/cli/setup.ts +164 -75
  30. package/src/decisions.ts +20 -1
  31. package/src/hub.ts +140 -30
  32. package/src/main.ts +43 -2
  33. package/src/omo/client.ts +28 -0
  34. package/src/protocol.ts +30 -1
  35. package/src/reply-notifications.ts +144 -0
  36. package/src/runtime/paths.ts +2 -0
  37. package/src/security-check.ts +241 -0
  38. package/src/store.ts +71 -1
  39. package/src/turn-stats.ts +49 -0
  40. package/src/work-watch.ts +36 -10
  41. package/src/work.ts +29 -4
  42. package/web/app.js +158 -0
  43. package/web/decisions.js +3 -0
  44. package/web/index.html +16 -0
  45. package/web/notifications.d.ts +25 -0
  46. package/web/notifications.js +118 -0
  47. package/web/style.css +12 -0
  48. package/web/turn-stats.d.ts +26 -0
  49. package/web/turn-stats.js +58 -0
@@ -0,0 +1,144 @@
1
+ import type { NotificationSettings, ReplyNotification } from "./protocol.ts";
2
+ import type { Store } from "./store.ts";
3
+
4
+ export interface NativeReplySource {
5
+ readonly sessionPath: string;
6
+ readonly conv: string;
7
+ readonly workId?: string;
8
+ readonly title: string;
9
+ }
10
+
11
+ export interface NativeReplyEntry {
12
+ readonly id?: string;
13
+ readonly type?: string;
14
+ readonly message?: {
15
+ readonly role?: string;
16
+ readonly content?: unknown;
17
+ readonly stopReason?: string;
18
+ readonly errorMessage?: string;
19
+ };
20
+ }
21
+
22
+ /** entry_appended carries a persisted entry, unlike message_end's identity-free message object. */
23
+ export function nativeReplyEntry(value: unknown): NativeReplyEntry | null {
24
+ if (typeof value !== "object" || value === null || !("id" in value) || typeof value.id !== "string" || !value.id ||
25
+ !("type" in value) || typeof value.type !== "string") return null;
26
+ const message = "message" in value ? value.message : undefined;
27
+ if (typeof message !== "object" || message === null) return { id: value.id, type: value.type };
28
+ return {
29
+ id: value.id, type: value.type,
30
+ message: {
31
+ role: "role" in message && typeof message.role === "string" ? message.role : undefined,
32
+ stopReason: "stopReason" in message && typeof message.stopReason === "string" ? message.stopReason : undefined,
33
+ errorMessage: "errorMessage" in message && typeof message.errorMessage === "string" ? message.errorMessage : undefined,
34
+ content: "content" in message ? message.content : undefined,
35
+ },
36
+ };
37
+ }
38
+
39
+ function replyText(entry: NativeReplyEntry): string | null {
40
+ const message = entry.type === "message" ? entry.message : undefined;
41
+ // Only native completed assistant messages qualify, including intermediate tool-use messages.
42
+ if (message?.role !== "assistant" || !["stop", "toolUse", "length"].includes(message.stopReason ?? "") || message.errorMessage) return null;
43
+ const content = message.content;
44
+ const text = typeof content === "string" ? content : Array.isArray(content)
45
+ ? content.flatMap((part: unknown) => typeof part === "object" && part !== null &&
46
+ "type" in part && part.type === "text" && "text" in part && typeof part.text === "string" ? [part.text] : []).join("")
47
+ : "";
48
+ return text.trim() ? text : null;
49
+ }
50
+
51
+ export class ReplyNotifications<Connection extends object> {
52
+ private readonly devices = new Map<Connection, { deviceId: string; sent: Set<string> }>();
53
+
54
+ constructor(private readonly store: Store, private readonly send: (connection: Connection, notification: ReplyNotification) => void) {}
55
+
56
+ settings(): NotificationSettings {
57
+ return { mode: this.store.getSetting("notifications.mode") === "all-replies" ? "all-replies" : "inbox-only" };
58
+ }
59
+
60
+ revision(): string {
61
+ return this.store.getSetting("notifications.revision") ?? "0";
62
+ }
63
+
64
+ /** The read's revision is captured before RPC, so a toggle cannot admit an older in-flight read. */
65
+ observe(source: NativeReplySource, entries: readonly NativeReplyEntry[], revision: string): void {
66
+ if (revision !== this.revision()) return;
67
+ for (const entry of entries) {
68
+ if (typeof entry.id !== "string" || !entry.id) throw new Error("reply_history_entry_without_id");
69
+ const text = replyText(entry);
70
+ const notification = this.settings().mode === "all-replies" && text !== null ? {
71
+ id: crypto.randomUUID(), conv: source.conv, title: source.title, text,
72
+ ...(source.workId ? { workId: source.workId } : {}),
73
+ } : null;
74
+ this.store.recordReplyNotification(source.sessionPath, entry.id, notification);
75
+ }
76
+ this.deliver();
77
+ }
78
+
79
+ setMode(mode: NotificationSettings["mode"]): void {
80
+ if (mode === this.settings().mode) return;
81
+ this.store.setNotificationMode(mode);
82
+ }
83
+
84
+ connectDevice(connection: Connection, deviceId: string): void {
85
+ if (this.devices.get(connection)?.deviceId === deviceId) return;
86
+ this.devices.set(connection, { deviceId, sent: new Set() });
87
+ this.deliver();
88
+ }
89
+
90
+ disconnectDevice(connection: Connection): void {
91
+ this.devices.delete(connection);
92
+ this.deliver();
93
+ }
94
+
95
+ ack(connection: Connection, id: string): boolean {
96
+ const device = this.devices.get(connection);
97
+ if (!device?.sent.has(id)) return false;
98
+ this.store.ackReplyNotification(id, device.deviceId);
99
+ return true;
100
+ }
101
+
102
+ /** Flushes committed occurrences, including explicitly published work replies. */
103
+ deliver(): void {
104
+ if (this.settings().mode !== "all-replies") return;
105
+ for (const [connection, device] of this.devices) {
106
+ for (const notification of this.store.pendingReplyNotifications(device.deviceId)) {
107
+ if ([...this.devices.values()].some((other) => other.deviceId === device.deviceId && other.sent.has(notification.id))) continue;
108
+ device.sent.add(notification.id);
109
+ this.send(connection, notification);
110
+ }
111
+ }
112
+ }
113
+ }
114
+
115
+ /** Native owners use this narrow seam; notification transport never observes synthetic chat publication. */
116
+ export type NativeReplyObserver = Pick<ReplyNotifications<object>, "observe" | "revision" | "settings">;
117
+
118
+ /** Writes serialize across awaited activation snapshots; rejected requests never reach the queue. */
119
+ export class NotificationSettingsApi {
120
+ private writes: Promise<unknown> = Promise.resolve();
121
+
122
+ constructor(private readonly notifications: Pick<ReplyNotifications<object>, "settings" | "setMode">,
123
+ private readonly baseline: () => Promise<void>, private readonly publish: (settings: NotificationSettings) => void) {}
124
+
125
+ async handle(req: Request): Promise<Response> {
126
+ if (req.method === "GET") return Response.json(this.notifications.settings());
127
+ if (req.method !== "PUT") return Response.json({ error: "method_not_allowed" }, { status: 405 });
128
+ const body: unknown = await req.json().catch(() => null);
129
+ if (typeof body !== "object" || body === null || Array.isArray(body) || Object.keys(body).length !== 1 ||
130
+ !("mode" in body) || (body.mode !== "inbox-only" && body.mode !== "all-replies")) {
131
+ return Response.json({ error: "invalid_notification_settings" }, { status: 400 });
132
+ }
133
+ const mode = body.mode;
134
+ const write = this.writes.then(async () => {
135
+ if (mode === "all-replies" && this.notifications.settings().mode !== mode) await this.baseline();
136
+ this.notifications.setMode(mode);
137
+ const settings = this.notifications.settings();
138
+ this.publish(settings);
139
+ return Response.json(settings);
140
+ });
141
+ this.writes = write.catch(() => undefined);
142
+ return write;
143
+ }
144
+ }
@@ -41,6 +41,7 @@ export interface RuntimePaths {
41
41
  readonly backups: string;
42
42
  readonly scheduleFile: string;
43
43
  readonly voiceSettingFile: string;
44
+ readonly securityCheckFile: string;
44
45
  readonly logs: string;
45
46
  readonly secrets: string;
46
47
  readonly omoTokenFile: string;
@@ -108,6 +109,7 @@ export function runtimePaths(env: Env = process.env, exists: (path: string) => b
108
109
  backups: join(data, "backups"),
109
110
  scheduleFile: value(env, "ZGW_SCHEDULE_FILE") ?? join(data, "schedule.json"),
110
111
  voiceSettingFile: value(env, "ZGW_VOICE_SETTING_FILE") ?? join(data, "voice-setting"),
112
+ securityCheckFile: value(env, "ZGW_SECURITY_CHECK_FILE") ?? join(data, "security-check"),
111
113
  logs: join(home, "logs"),
112
114
  secrets,
113
115
  omoTokenFile: value(env, "ZGW_OMO_TOKEN_FILE") ?? join(secrets, "omo-ws-token"),
@@ -0,0 +1,241 @@
1
+ import { entropy, isAllowed, type AllowEntry, type Rule } from "../scripts/secret-rules.ts";
2
+ import ruleConfig from "../scripts/secret-scan.rules.json";
3
+ import allowConfig from "../scripts/secret-scan.allow.json";
4
+ import { readVoiceSetting, writeVoiceSetting } from "./voice-setting.ts";
5
+
6
+ // Improvement 165: every user text bound for the engine passes one security check first (work 779e5b0e).
7
+ // The rules, the allow list and the matching helpers are the repository security check's own (scripts/secret-rules.ts,
8
+ // scripts/secret-scan.rules.json, scripts/secret-scan.allow.json; docs/security-check.md); this module only compiles them
9
+ // once and runs them in memory. No network, disk or process call happens per message.
10
+
11
+ export type SecurityRule = Rule;
12
+ export type SecurityAllow = AllowEntry;
13
+ /** One kind of finding and its count; the form that may reach logs. */
14
+ export interface SecurityFinding { readonly kind: string; readonly label: string; readonly count: number }
15
+ /** One matched value and where it is (1-based line and column of text `text`). Only the user's own screens get these. */
16
+ export interface SecurityOccurrence {
17
+ readonly kind: string;
18
+ readonly label: string;
19
+ readonly value: string;
20
+ readonly text: number;
21
+ readonly line: number;
22
+ readonly column: number;
23
+ }
24
+ /** A message held back by the check until the user sends or drops it; kept in gateway memory only. */
25
+ export interface HeldMessage {
26
+ readonly id: string;
27
+ readonly conv: string;
28
+ readonly source: "message" | "answer";
29
+ readonly createdAt: string;
30
+ readonly findings: readonly SecurityFinding[];
31
+ readonly occurrences: readonly SecurityOccurrence[];
32
+ /** The user's own text, so the screen can put it back into the composer when the message is dropped or lost. */
33
+ readonly draft?: string;
34
+ }
35
+
36
+ export const SECURITY_CHECK_PATH = "/api/settings/security-check";
37
+ export const SECURITY_BLOCKED = "security_check_blocked";
38
+ export const SECURITY_HELD_PATH = "/api/security-check/held";
39
+
40
+ /** User-facing names of the finding kinds; rule ids without an entry fall back to "기타 보안 값". */
41
+ const KIND_OF_RULE: Readonly<Record<string, string>> = {
42
+ "private-key": "private-key",
43
+ "ssh-public-key": "private-key",
44
+ "github-token": "token", "npm-token": "token", "anthropic-key": "token", "openai-key": "token", "notion-token": "token",
45
+ "slack-token": "token", "slack-webhook": "token", "aws-access-key": "token", "gcp-api-key": "token", "tailscale-key": "token",
46
+ "jwt": "token", "high-entropy-assignment": "token",
47
+ "credential-assignment": "credential", "authorization": "credential", "cookie-header": "credential",
48
+ "user-home-path": "home-path",
49
+ "ipv4": "internal-address", "ipv6-internal": "internal-address", "internal-host": "internal-address", "internal-url": "internal-address",
50
+ "email": "email", "phone-number": "phone",
51
+ "adb-device": "device-id", "device-serial": "device-id",
52
+ };
53
+ export const KIND_LABELS: Readonly<Record<string, string>> = {
54
+ "private-key": "비밀키",
55
+ "token": "토큰·API 키",
56
+ "credential": "비밀번호·인증·쿠키 값",
57
+ "home-path": "홈 폴더 경로",
58
+ "internal-address": "내부 IP·호스트",
59
+ "email": "이메일 주소",
60
+ "phone": "전화번호",
61
+ "device-id": "기기 식별값",
62
+ "other": "기타 보안 값",
63
+ };
64
+
65
+ interface Compiled { readonly id: string; readonly kind: string; readonly regex: RegExp; readonly group: number; readonly minEntropy?: number }
66
+
67
+ /** The repository rules and allow list, as shipped in the package. */
68
+ export function defaultChecker(): SecurityChecker {
69
+ return new SecurityChecker(ruleConfig.rules as Rule[], allowConfig.allow as AllowEntry[]);
70
+ }
71
+
72
+ /** Compiles every rule once; scan() reuses the compiled expressions for every message. */
73
+ export class SecurityChecker {
74
+ private readonly rules: readonly Compiled[];
75
+
76
+ constructor(rules: readonly SecurityRule[], private readonly allow: readonly SecurityAllow[] = []) {
77
+ this.rules = rules.map((rule) => ({
78
+ id: rule.id,
79
+ kind: KIND_OF_RULE[rule.id] ?? "other",
80
+ regex: new RegExp(rule.pattern, `${(rule.flags ?? "").replace(/[gy]/g, "")}g`),
81
+ group: rule.group ?? 0,
82
+ ...(rule.minEntropy === undefined ? {} : { minEntropy: rule.minEntropy }),
83
+ }));
84
+ }
85
+
86
+ /** Counts per kind over all texts, in KIND_LABELS order; empty when nothing matched. */
87
+ scan(texts: readonly string[]): SecurityFinding[] {
88
+ return countFindings(this.occurrences(texts));
89
+ }
90
+
91
+ /** Every matched value with its place, in text order then rule order. */
92
+ occurrences(texts: readonly string[]): SecurityOccurrence[] {
93
+ const found: SecurityOccurrence[] = [];
94
+ for (const [index, text] of texts.entries()) {
95
+ if (!text) continue;
96
+ for (const rule of this.rules) {
97
+ const regex = rule.regex;
98
+ regex.lastIndex = 0;
99
+ let match: RegExpExecArray | null;
100
+ while ((match = regex.exec(text)) !== null) {
101
+ if (match[0].length === 0) regex.lastIndex += 1;
102
+ const value = match[rule.group];
103
+ if (!value) continue;
104
+ if (rule.minEntropy !== undefined && entropy(value) < rule.minEntropy) continue;
105
+ if (isAllowed(rule.id, value, this.allow)) continue;
106
+ const at = match.index + (rule.group === 0 ? 0 : match[0].indexOf(value));
107
+ const before = text.slice(0, at);
108
+ const line = before.split("\n").length;
109
+ found.push({ kind: rule.kind, label: KIND_LABELS[rule.kind]!, value, text: index, line, column: at - before.lastIndexOf("\n") });
110
+ }
111
+ }
112
+ }
113
+ return found;
114
+ }
115
+ }
116
+
117
+ export function countFindings(occurrences: readonly SecurityOccurrence[]): SecurityFinding[] {
118
+ const counts = new Map<string, number>();
119
+ for (const occurrence of occurrences) counts.set(occurrence.kind, (counts.get(occurrence.kind) ?? 0) + 1);
120
+ return Object.keys(KIND_LABELS).filter((kind) => counts.has(kind))
121
+ .map((kind) => ({ kind, label: KIND_LABELS[kind]!, count: counts.get(kind)! }));
122
+ }
123
+
124
+ /** "토큰·API 키 2건, 홈 폴더 경로 1건" */
125
+ export function findingSummary(findings: readonly SecurityFinding[]): string {
126
+ return findings.map((finding) => `${finding.label} ${finding.count}건`).join(", ");
127
+ }
128
+
129
+ export function blockedMessage(findings: readonly SecurityFinding[]): string {
130
+ return `보안검사에 걸려 엔진에 보내지 않고 보류했습니다: ${findingSummary(findings)}.`;
131
+ }
132
+
133
+ /** Thrown for a held decision answer; `held` (with the values) goes only to the user's screen that sent it. */
134
+ export class SecurityCheckError extends Error {
135
+ readonly code = SECURITY_BLOCKED;
136
+ constructor(readonly held: HeldMessage) {
137
+ super(blockedMessage(held.findings));
138
+ }
139
+ }
140
+
141
+ /**
142
+ * The on/off setting (default on) and the checker. The setting file uses the voice setting format ("off" means off,
143
+ * anything else or a missing file means on); it is read once at start and kept in memory, so a check reads no disk.
144
+ */
145
+ export class MessageGuard {
146
+ private on: boolean;
147
+ private readonly held = new Map<string, { info: HeldMessage; send: () => Promise<void> }>();
148
+
149
+ /** `onHold` sends each new held entry (with its values) straight to the user's screens; never to omo or the store. */
150
+ constructor(private readonly checker: SecurityChecker, readonly file: string | null, private readonly onHold: (held: HeldMessage) => void = () => {}) {
151
+ this.on = file === null ? true : readVoiceSetting(file);
152
+ }
153
+
154
+ enabled(): boolean {
155
+ return this.on;
156
+ }
157
+
158
+ set(enabled: boolean): boolean {
159
+ if (this.file !== null) writeVoiceSetting(this.file, enabled);
160
+ this.on = enabled;
161
+ return enabled;
162
+ }
163
+
164
+ /** Matched values of the texts, or null when the check is off or nothing matched. */
165
+ check(texts: readonly string[]): SecurityOccurrence[] | null {
166
+ if (!this.on) return null;
167
+ const found = this.checker.occurrences(texts);
168
+ return found.length > 0 ? found : null;
169
+ }
170
+
171
+ /** Keeps `send` until the user decides; nothing is written to disk. */
172
+ hold(conv: string, source: HeldMessage["source"], occurrences: readonly SecurityOccurrence[], send: () => Promise<void>, draft?: string): HeldMessage {
173
+ const info: HeldMessage = {
174
+ id: crypto.randomUUID(), conv, source, createdAt: new Date().toISOString(),
175
+ findings: countFindings(occurrences), occurrences, ...(draft === undefined ? {} : { draft }),
176
+ };
177
+ this.held.set(info.id, { info, send });
178
+ this.onHold(info);
179
+ return info;
180
+ }
181
+
182
+ /** Held when check() finds anything: throws SecurityCheckError with the held entry. */
183
+ holdOrPass(conv: string, source: HeldMessage["source"], texts: readonly string[], send: () => Promise<void>): void {
184
+ const found = this.check(texts);
185
+ if (found) throw new SecurityCheckError(this.hold(conv, source, found, send));
186
+ }
187
+
188
+ list(): HeldMessage[] {
189
+ return [...this.held.values()].map((entry) => entry.info);
190
+ }
191
+
192
+ /** Removes the entry; "sent" runs its send. null when the id is unknown (already decided, or lost by a restart). */
193
+ async decide(id: string, send: boolean): Promise<"sent" | "dropped" | null> {
194
+ const entry = this.held.get(id);
195
+ if (!entry) return null;
196
+ this.held.delete(id);
197
+ if (!send) return "dropped";
198
+ await entry.send();
199
+ return "sent";
200
+ }
201
+ }
202
+
203
+ /** GET (both ports) and PUT (trusted web port) of SECURITY_CHECK_PATH. */
204
+ export async function handleSecurityCheckSetting(
205
+ req: Request,
206
+ trusted: boolean,
207
+ guard: Pick<MessageGuard, "enabled" | "set">,
208
+ publish: (enabled: boolean) => void,
209
+ ): Promise<Response | null> {
210
+ if (req.method === "GET") return Response.json({ enabled: guard.enabled() });
211
+ if (req.method !== "PUT") return null;
212
+ if (!trusted) return Response.json({ error: "forbidden" }, { status: 403 });
213
+ const body = (await req.json().catch(() => null)) as { enabled?: unknown } | null;
214
+ if (typeof body?.enabled !== "boolean") return Response.json({ error: "enabled_required" }, { status: 400 });
215
+ const enabled = guard.set(body.enabled);
216
+ publish(enabled);
217
+ return Response.json({ enabled });
218
+ }
219
+
220
+ /** GET SECURITY_HELD_PATH lists held messages; POST SECURITY_HELD_PATH/<id> {send} sends or drops one. */
221
+ export async function handleHeld(
222
+ req: Request,
223
+ url: URL,
224
+ guard: Pick<MessageGuard, "list" | "decide">,
225
+ resolved: (id: string, outcome: "sent" | "dropped") => void,
226
+ ): Promise<Response | null> {
227
+ if (url.pathname === SECURITY_HELD_PATH) return req.method === "GET" ? Response.json({ held: guard.list() }) : null;
228
+ if (!url.pathname.startsWith(`${SECURITY_HELD_PATH}/`) || req.method !== "POST") return null;
229
+ const id = decodeURIComponent(url.pathname.slice(SECURITY_HELD_PATH.length + 1));
230
+ const body = (await req.json().catch(() => null)) as { send?: unknown } | null;
231
+ if (typeof body?.send !== "boolean") return Response.json({ error: "send_required" }, { status: 400 });
232
+ let outcome: "sent" | "dropped" | null;
233
+ try {
234
+ outcome = await guard.decide(id, body.send);
235
+ } catch (error) {
236
+ return Response.json({ error: "send_failed", message: error instanceof Error ? error.message : String(error) }, { status: 502 });
237
+ }
238
+ if (outcome === null) return Response.json({ error: "held_not_found" }, { status: 404 });
239
+ resolved(id, outcome);
240
+ return Response.json({ id, outcome });
241
+ }
package/src/store.ts CHANGED
@@ -3,7 +3,8 @@ import { OMO_CHECK_BASELINE, omoCheckActionable, omoCheckDaytime } from "./omo-c
3
3
  import { mkdirSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { isDeepStrictEqual } from "node:util";
6
- import type { ConversationInfo, DecisionAnswerRequest } from "./protocol.ts";
6
+ import type { ConversationInfo, DecisionAnswerRequest, NotificationMode, ReplyNotification } from "./protocol.ts";
7
+ import type { NativeTurnStats } from "./turn-stats.ts";
7
8
 
8
9
  export interface StoredEvent {
9
10
  seq: number;
@@ -517,6 +518,19 @@ export class Store {
517
518
  acked_at INTEGER
518
519
  );
519
520
  CREATE INDEX IF NOT EXISTS reminders_fire ON reminders(fired_at, fire_at);
521
+ CREATE TABLE IF NOT EXISTS reply_notifications (
522
+ session_path TEXT NOT NULL,
523
+ entry_id TEXT NOT NULL,
524
+ id TEXT NOT NULL UNIQUE,
525
+ data TEXT,
526
+ cancelled INTEGER NOT NULL DEFAULT 0,
527
+ PRIMARY KEY (session_path, entry_id)
528
+ );
529
+ CREATE TABLE IF NOT EXISTS reply_notification_acks (
530
+ id TEXT NOT NULL,
531
+ device_id TEXT NOT NULL,
532
+ PRIMARY KEY (id, device_id)
533
+ );
520
534
  CREATE TABLE IF NOT EXISTS omo_ledger (
521
535
  conv TEXT PRIMARY KEY,
522
536
  session_path TEXT NOT NULL,
@@ -977,6 +991,30 @@ export class Store {
977
991
  return { seq: Number(result.lastInsertRowid), conv, type, data, ts };
978
992
  }
979
993
 
994
+ /** Bind to one persisted native answer; replay never creates another statistics row. */
995
+ appendTurnStats(conv: string, durableSessionId: string, measured: NativeTurnStats): StoredEvent | null {
996
+ return this.db.transaction(() => {
997
+ const existing = this.db.query<{ seq: number }, [string, string]>(
998
+ "SELECT seq FROM events WHERE conv = ? AND type = 'message.stats' AND json_extract(data, '$.turnId') = ? LIMIT 1",
999
+ ).get(conv, measured.turnId);
1000
+ if (existing) return null;
1001
+ const answer = this.db.query<{ seq: number; itemId: string }, [string, string, string, number, number]>(
1002
+ `SELECT seq, json_extract(data, '$.itemId') AS itemId FROM events
1003
+ WHERE conv = ? AND type = 'message.final'
1004
+ AND json_extract(data, '$.engineTurn.sessionId') = ?
1005
+ AND json_extract(data, '$.engineTurn.id') = ?
1006
+ AND json_extract(data, '$.engineTurn.messageTimestamp') = ?
1007
+ AND json_extract(data, '$.engineTurn.messageIndex') = ?
1008
+ AND json_type(data, '$.itemId') = 'text'
1009
+ ORDER BY seq DESC LIMIT 1`,
1010
+ ).get(conv, durableSessionId, measured.turnId, measured.finalMessageTimestamp, measured.finalMessageIndex);
1011
+ if (!answer) return null;
1012
+ return this.appendEvent(conv, "message.stats", {
1013
+ itemId: answer.itemId, messageSeq: answer.seq, turnId: measured.turnId, stats: measured.stats,
1014
+ });
1015
+ }).immediate();
1016
+ }
1017
+
980
1018
  /** Acceptance and definitely-unsent intent precede every session-admission await. */
981
1019
  acceptMainUser(conv: string, data: unknown): StoredEvent {
982
1020
  return this.db.transaction(() => {
@@ -1043,6 +1081,31 @@ export class Store {
1043
1081
  this.db.query("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value);
1044
1082
  }
1045
1083
 
1084
+ /** A source occurrence is recorded even while disabled, making the eligibility boundary durable. */
1085
+ recordReplyNotification(sessionPath: string, entryId: string, notification: ReplyNotification | null): void {
1086
+ this.db.query("INSERT OR IGNORE INTO reply_notifications (session_path, entry_id, id, data) VALUES (?, ?, ?, ?)")
1087
+ .run(sessionPath, entryId, notification?.id ?? crypto.randomUUID(), notification ? JSON.stringify(notification) : null);
1088
+ }
1089
+
1090
+ pendingReplyNotifications(deviceId: string): ReplyNotification[] {
1091
+ return this.db.query<{ data: string }, [string]>(`SELECT data FROM reply_notifications AS n
1092
+ WHERE data IS NOT NULL AND cancelled = 0 AND NOT EXISTS
1093
+ (SELECT 1 FROM reply_notification_acks AS a WHERE a.id = n.id AND a.device_id = ?) ORDER BY n.rowid`)
1094
+ .all(deviceId).map((row) => JSON.parse(row.data) as ReplyNotification);
1095
+ }
1096
+
1097
+ ackReplyNotification(id: string, deviceId: string): void {
1098
+ this.db.query("INSERT OR IGNORE INTO reply_notification_acks (id, device_id) VALUES (?, ?)").run(id, deviceId);
1099
+ }
1100
+
1101
+ setNotificationMode(mode: NotificationMode): void {
1102
+ this.db.transaction(() => {
1103
+ if (mode === "inbox-only") this.db.query("UPDATE reply_notifications SET cancelled = 1 WHERE data IS NOT NULL").run();
1104
+ this.setSetting("notifications.mode", mode);
1105
+ this.setSetting("notifications.revision", String(Number(this.getSetting("notifications.revision") ?? 0) + 1));
1106
+ }).immediate();
1107
+ }
1108
+
1046
1109
  getMemo(): Memo {
1047
1110
  const row = this.db
1048
1111
  .query<{ text: string; width: number | null; height: number | null; images: string; updated_at: string }, []>(
@@ -1476,6 +1539,13 @@ export class Store {
1476
1539
  }
1477
1540
  if (unreported.length !== updates.length) throw new WorkStoreError("publish_conflict", unreported.map((u) => u.id));
1478
1541
  const event = this.appendEvent(ownerConv, "message.final", { text, work_update_ids: updates.map((update) => update.id) });
1542
+ // Publishing is a distinct complete chat reply, not a replay of the worker's native message.
1543
+ // Admit it in this transaction so a restart between commit and fan-out cannot lose it.
1544
+ this.recordReplyNotification("gateway:work-publication", String(event.seq),
1545
+ this.getSetting("notifications.mode") === "all-replies" && text.trim() ? {
1546
+ id: crypto.randomUUID(), conv: ownerConv,
1547
+ title: this.getConversation(ownerConv)?.title ?? "Reply", text,
1548
+ } : null);
1479
1549
  for (const update of updates) this.db.query("UPDATE work_updates SET reported_event_seq = ? WHERE id = ?").run(event.seq, update.id);
1480
1550
  return [event];
1481
1551
  }).immediate();
@@ -0,0 +1,49 @@
1
+ import type { TurnStats } from "./protocol.ts";
2
+
3
+ export interface NativeTurnStats {
4
+ readonly version: 1;
5
+ readonly turnId: string;
6
+ readonly finalMessageTimestamp: number;
7
+ readonly finalMessageIndex: number;
8
+ readonly stats: TurnStats;
9
+ }
10
+
11
+ function record(value: unknown): value is Record<string, unknown> {
12
+ return typeof value === "object" && value !== null && !Array.isArray(value);
13
+ }
14
+
15
+ export function turnId(value: unknown): string | null {
16
+ return typeof value === "string" && /^[a-zA-Z0-9_-]{1,128}$/.test(value) ? value : null;
17
+ }
18
+
19
+ export function messageTimestamp(value: unknown): value is number {
20
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
21
+ }
22
+
23
+ /** Parse the engine boundary once. Unknown fields never reach stored/client data. */
24
+ export function nativeTurnStats(value: unknown): NativeTurnStats | null {
25
+ if (!record(value) || value.version !== 1) return null;
26
+ const id = turnId(value.turnId);
27
+ if (!id || !messageTimestamp(value.finalMessageTimestamp) || !record(value.stats)) return null;
28
+ if (typeof value.finalMessageIndex !== "number" || !Number.isSafeInteger(value.finalMessageIndex) || value.finalMessageIndex <= 0) return null;
29
+ const { tokensPerSecond, modelDurationMs, cacheHitPercent } = value.stats;
30
+ if (tokensPerSecond !== undefined &&
31
+ (typeof tokensPerSecond !== "number" || !Number.isFinite(tokensPerSecond) || tokensPerSecond <= 0)) return null;
32
+ if (modelDurationMs !== undefined &&
33
+ (typeof modelDurationMs !== "number" || !Number.isFinite(modelDurationMs) || modelDurationMs <= 0)) return null;
34
+ if (cacheHitPercent !== undefined &&
35
+ (typeof cacheHitPercent !== "number" || !Number.isFinite(cacheHitPercent) || cacheHitPercent < 0 || cacheHitPercent > 100)) return null;
36
+ if (tokensPerSecond === undefined && modelDurationMs === undefined && cacheHitPercent === undefined) return null;
37
+ return {
38
+ version: 1, turnId: id, finalMessageTimestamp: value.finalMessageTimestamp, finalMessageIndex: value.finalMessageIndex,
39
+ stats: {
40
+ ...(tokensPerSecond === undefined ? {} : { tokensPerSecond }),
41
+ ...(modelDurationMs === undefined ? {} : { modelDurationMs }),
42
+ ...(cacheHitPercent === undefined ? {} : { cacheHitPercent }),
43
+ },
44
+ };
45
+ }
46
+
47
+ export function nativeTurnStarted(value: unknown): string | null {
48
+ return record(value) && value.version === 1 ? turnId(value.turnId) : null;
49
+ }
package/src/work-watch.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { OmoEvent } from "./omo/client.ts";
1
+ import { blockingCompaction, COMPACTION_FAILURE_KINDS, compactionOutcome, type OmoEvent } from "./omo/client.ts";
2
2
  import type { Store, WorkCommand, WorkItem, WorkUpdate } from "./store.ts";
3
3
 
4
4
  /**
@@ -68,7 +68,7 @@ export interface Verdict {
68
68
  readonly evidence?: Readonly<Record<string, unknown>>;
69
69
  }
70
70
 
71
- /** Live signals of one work session, kept in memory only and reset whenever the OMO connection changes. */
71
+ /** Live runtime signals reset on connection changes; safe compaction outcomes reload from work observations. */
72
72
  export interface Live {
73
73
  observedSince: number;
74
74
  lastActivityAt: number;
@@ -81,11 +81,12 @@ export interface Live {
81
81
  readonly openTools: Map<string, { name: string; at: number }>;
82
82
  /** Running sub-tasks from senpi-task's `omo.task.updated`; null while unknown. */
83
83
  children: { active: number; at: number } | null;
84
+ readonly compactionTerminals: Set<string>;
84
85
  lastError: { kind: "model_error" | "compaction_failed" | "retry_exhausted"; message: string; at: number } | null;
85
86
  }
86
87
 
87
88
  export function newLive(now: number): Live {
88
- return { observedSince: now, lastActivityAt: now, streaming: null, runStartedAt: null, runObserved: false, stoppedAt: null, openTools: new Map(), children: null, lastError: null };
89
+ return { observedSince: now, lastActivityAt: now, streaming: null, runStartedAt: null, runObserved: false, stoppedAt: null, openTools: new Map(), children: null, compactionTerminals: new Set(), lastError: null };
89
90
  }
90
91
 
91
92
  export interface JudgeInput {
@@ -270,6 +271,15 @@ export class WorkWatcher {
270
271
  let live = this.lives.get(workId);
271
272
  if (!live) {
272
273
  live = newLive(this.clock());
274
+ for (const update of this.store.listWorkUpdates(workId)) {
275
+ const details = update.details;
276
+ if (update.kind !== "observation" || !record(details) || details.source !== "compaction-watcher" ||
277
+ typeof details.at !== "number" || !Number.isFinite(new Date(details.at).getTime())) continue;
278
+ if (typeof details.requestId === "string") live.compactionTerminals.add(details.requestId);
279
+ const failureKind = COMPACTION_FAILURE_KINDS.find((kind) => kind === details.failureKind);
280
+ if (failureKind) live.lastError = { kind: "compaction_failed", message: failureKind, at: details.at };
281
+ else if (details.failureKind === null) live.lastError = null;
282
+ }
273
283
  this.lives.set(workId, live);
274
284
  }
275
285
  return live;
@@ -306,8 +316,22 @@ export class WorkWatcher {
306
316
  const now = this.clock();
307
317
  const live = this.live(workId);
308
318
  live.lastActivityAt = now;
319
+ const previousError = live.lastError;
320
+ const changed = this.apply(live, event, now);
321
+ if (previousError !== live.lastError &&
322
+ (previousError?.kind === "compaction_failed" || live.lastError?.kind === "compaction_failed")) {
323
+ this.store.recordWorkUpdate(workId, {
324
+ sourceKey: `watch:compaction:${crypto.randomUUID()}`,
325
+ kind: "observation",
326
+ commandSeq: null,
327
+ summary: "compaction_state",
328
+ details: { source: "compaction-watcher", at: now,
329
+ failureKind: live.lastError?.kind === "compaction_failed" ? live.lastError.message : null,
330
+ requestId: event.type === "compaction_end" && typeof event.requestId === "string" ? event.requestId : null },
331
+ });
332
+ }
309
333
  // Plain activity changes a verdict only when it clears an unresponsive or broken one.
310
- if (this.apply(live, event, now) || this.flagged(workId)) this.schedule();
334
+ if (changed || this.flagged(workId)) this.schedule();
311
335
  }
312
336
 
313
337
  private flagged(workId: string): boolean {
@@ -351,12 +375,14 @@ export class WorkWatcher {
351
375
  return true;
352
376
  }
353
377
  case "compaction_end": {
354
- if (event.aborted === true) return false;
355
- const failed = typeof event.errorMessage === "string" || event.accepted === false ||
356
- (event.accepted !== true && !record(event.result));
357
- if (!failed) return false;
358
- const cause = typeof event.errorMessage === "string" ? event.errorMessage : typeof event.rejectionCause === "string" ? event.rejectionCause : "완료 결과가 없습니다";
359
- live.lastError = { kind: "compaction_failed", message: cause, at: now };
378
+ if (!blockingCompaction(event)) return false;
379
+ if (typeof event.requestId === "string") {
380
+ if (live.compactionTerminals.has(event.requestId)) return false;
381
+ live.compactionTerminals.add(event.requestId);
382
+ }
383
+ const outcome = compactionOutcome(event);
384
+ if (outcome.status !== "failed") return false;
385
+ live.lastError = { kind: "compaction_failed", message: outcome.failureKind, at: now };
360
386
  return true;
361
387
  }
362
388
  case "auto_retry_end":