@geravant/sinain 1.0.19 → 1.2.0

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 (80) hide show
  1. package/README.md +10 -1
  2. package/cli.js +176 -0
  3. package/index.ts +4 -2
  4. package/install.js +89 -14
  5. package/launcher.js +622 -0
  6. package/openclaw.plugin.json +4 -0
  7. package/pack-prepare.js +48 -0
  8. package/package.json +24 -5
  9. package/sense_client/README.md +82 -0
  10. package/sense_client/__init__.py +1 -0
  11. package/sense_client/__main__.py +462 -0
  12. package/sense_client/app_detector.py +54 -0
  13. package/sense_client/app_detector_win.py +83 -0
  14. package/sense_client/capture.py +215 -0
  15. package/sense_client/capture_win.py +88 -0
  16. package/sense_client/change_detector.py +86 -0
  17. package/sense_client/config.py +64 -0
  18. package/sense_client/gate.py +145 -0
  19. package/sense_client/ocr.py +347 -0
  20. package/sense_client/privacy.py +65 -0
  21. package/sense_client/requirements.txt +13 -0
  22. package/sense_client/roi_extractor.py +84 -0
  23. package/sense_client/sender.py +173 -0
  24. package/sense_client/tests/__init__.py +0 -0
  25. package/sense_client/tests/test_stream1_optimizations.py +234 -0
  26. package/setup-overlay.js +82 -0
  27. package/sinain-agent/.env.example +17 -0
  28. package/sinain-agent/CLAUDE.md +87 -0
  29. package/sinain-agent/mcp-config.json +12 -0
  30. package/sinain-agent/run.sh +248 -0
  31. package/sinain-core/.env.example +93 -0
  32. package/sinain-core/package-lock.json +552 -0
  33. package/sinain-core/package.json +21 -0
  34. package/sinain-core/src/agent/analyzer.ts +366 -0
  35. package/sinain-core/src/agent/context-window.ts +172 -0
  36. package/sinain-core/src/agent/loop.ts +404 -0
  37. package/sinain-core/src/agent/situation-writer.ts +187 -0
  38. package/sinain-core/src/agent/traits.ts +520 -0
  39. package/sinain-core/src/audio/capture-spawner-macos.ts +44 -0
  40. package/sinain-core/src/audio/capture-spawner-win.ts +37 -0
  41. package/sinain-core/src/audio/capture-spawner.ts +14 -0
  42. package/sinain-core/src/audio/pipeline.ts +335 -0
  43. package/sinain-core/src/audio/transcription-local.ts +141 -0
  44. package/sinain-core/src/audio/transcription.ts +278 -0
  45. package/sinain-core/src/buffers/feed-buffer.ts +71 -0
  46. package/sinain-core/src/buffers/sense-buffer.ts +425 -0
  47. package/sinain-core/src/config.ts +245 -0
  48. package/sinain-core/src/escalation/escalation-slot.ts +136 -0
  49. package/sinain-core/src/escalation/escalator.ts +828 -0
  50. package/sinain-core/src/escalation/message-builder.ts +370 -0
  51. package/sinain-core/src/escalation/openclaw-ws.ts +726 -0
  52. package/sinain-core/src/escalation/scorer.ts +166 -0
  53. package/sinain-core/src/index.ts +537 -0
  54. package/sinain-core/src/learning/feedback-store.ts +253 -0
  55. package/sinain-core/src/learning/signal-collector.ts +218 -0
  56. package/sinain-core/src/log.ts +24 -0
  57. package/sinain-core/src/overlay/commands.ts +126 -0
  58. package/sinain-core/src/overlay/ws-handler.ts +267 -0
  59. package/sinain-core/src/privacy/index.ts +18 -0
  60. package/sinain-core/src/privacy/presets.ts +40 -0
  61. package/sinain-core/src/privacy/redact.ts +92 -0
  62. package/sinain-core/src/profiler.ts +181 -0
  63. package/sinain-core/src/recorder.ts +186 -0
  64. package/sinain-core/src/server.ts +456 -0
  65. package/sinain-core/src/trace/trace-store.ts +73 -0
  66. package/sinain-core/src/trace/tracer.ts +94 -0
  67. package/sinain-core/src/types.ts +427 -0
  68. package/sinain-core/src/util/dedup.ts +48 -0
  69. package/sinain-core/src/util/task-store.ts +84 -0
  70. package/sinain-core/tsconfig.json +18 -0
  71. package/sinain-knowledge/curation/engine.ts +137 -24
  72. package/sinain-knowledge/data/git-store.ts +26 -0
  73. package/sinain-knowledge/data/store.ts +117 -0
  74. package/sinain-mcp-server/index.ts +417 -0
  75. package/sinain-mcp-server/package.json +19 -0
  76. package/sinain-mcp-server/tsconfig.json +15 -0
  77. package/sinain-memory/graph_query.py +185 -0
  78. package/sinain-memory/knowledge_integrator.py +450 -0
  79. package/sinain-memory/memory-config.json +3 -1
  80. package/sinain-memory/session_distiller.py +162 -0
@@ -0,0 +1,186 @@
1
+ import type { FeedItem, SenseEvent, RecordCommand, RecorderStatus, StopResult } from "./types.js";
2
+ import { log, warn } from "./log.js";
3
+
4
+ const TAG = "recorder";
5
+
6
+ /**
7
+ * Recorder collects audio transcripts during a recording session.
8
+ *
9
+ * Usage:
10
+ * - handleCommand({command: "start", label: "Meeting"}) → starts recording
11
+ * - onFeedItem() → called for each audio transcript, collects if recording
12
+ * - onSenseEvent() → tracks app context for title detection
13
+ * - handleCommand({command: "stop"}) → stops recording, returns StopResult with transcript
14
+ * - getStatus() → returns current RecorderStatus for prompt injection
15
+ */
16
+ export class Recorder {
17
+ private recording = false;
18
+ private label: string | null = null;
19
+ private startedAt: number | null = null;
20
+ private segments: { text: string; ts: number }[] = [];
21
+ private lastApp: string = "";
22
+ private lastWindowTitle: string = "";
23
+
24
+ /**
25
+ * Handle a record command from the analyzer.
26
+ * Returns StopResult on stop if there are segments, otherwise undefined.
27
+ */
28
+ handleCommand(cmd: RecordCommand | undefined): StopResult | undefined {
29
+ if (!cmd) return undefined;
30
+
31
+ if (cmd.command === "start") {
32
+ return this.start(cmd.label);
33
+ } else if (cmd.command === "stop") {
34
+ return this.stop();
35
+ }
36
+ return undefined;
37
+ }
38
+
39
+ /**
40
+ * Start a new recording session.
41
+ */
42
+ private start(label?: string): undefined {
43
+ if (this.recording) {
44
+ log(TAG, `already recording "${this.label}" — ignoring start`);
45
+ return undefined;
46
+ }
47
+
48
+ this.recording = true;
49
+ this.label = label || null;
50
+ this.startedAt = Date.now();
51
+ this.segments = [];
52
+
53
+ const labelStr = label ? ` "${label}"` : "";
54
+ log(TAG, `started recording${labelStr}`);
55
+ return undefined;
56
+ }
57
+
58
+ /**
59
+ * Stop recording and return the collected transcript.
60
+ */
61
+ private stop(): StopResult | undefined {
62
+ if (!this.recording) {
63
+ log(TAG, "not recording — ignoring stop");
64
+ return undefined;
65
+ }
66
+
67
+ const title = this.buildTitle();
68
+ const transcript = this.buildTranscript();
69
+ const segments = this.segments.length;
70
+ const durationS = this.startedAt ? Math.round((Date.now() - this.startedAt) / 1000) : 0;
71
+
72
+ // Reset state
73
+ this.recording = false;
74
+ this.label = null;
75
+ this.startedAt = null;
76
+ this.segments = [];
77
+
78
+ log(TAG, `stopped recording: "${title}" (${segments} segments, ${durationS}s)`);
79
+
80
+ if (segments === 0) {
81
+ log(TAG, "no segments captured — returning undefined");
82
+ return undefined;
83
+ }
84
+
85
+ return { title, transcript, segments, durationS };
86
+ }
87
+
88
+ /**
89
+ * Called for each audio FeedItem. Collects if recording.
90
+ */
91
+ onFeedItem(item: FeedItem): void {
92
+ if (!this.recording) return;
93
+ if (item.source !== "audio") return;
94
+ if (!item.text || item.text.trim().length === 0) return;
95
+
96
+ // Strip audio source prefixes if present ([📝], [🔊], [🎤])
97
+ let text = item.text;
98
+ if (text.startsWith("[📝] ")) {
99
+ text = text.slice(5);
100
+ } else if (text.startsWith("[🔊] ")) {
101
+ text = text.slice(5);
102
+ } else if (text.startsWith("[🎤] ")) {
103
+ text = text.slice(5);
104
+ }
105
+
106
+ this.segments.push({ text: text.trim(), ts: item.ts });
107
+ }
108
+
109
+ /**
110
+ * Called for each SenseEvent. Tracks app context for title.
111
+ */
112
+ onSenseEvent(event: SenseEvent): void {
113
+ if (event.meta.app) {
114
+ this.lastApp = event.meta.app;
115
+ }
116
+ if (event.meta.windowTitle) {
117
+ this.lastWindowTitle = event.meta.windowTitle;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Get current recorder status for prompt injection.
123
+ */
124
+ getStatus(): RecorderStatus {
125
+ return {
126
+ recording: this.recording,
127
+ label: this.label,
128
+ startedAt: this.startedAt,
129
+ segments: this.segments.length,
130
+ durationMs: this.startedAt ? Date.now() - this.startedAt : 0,
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Build a title from label or app context.
136
+ */
137
+ private buildTitle(): string {
138
+ if (this.label) return this.label;
139
+
140
+ // Try to build from app context
141
+ const app = this.lastApp.replace(/\.app$/i, "").trim();
142
+ if (app) {
143
+ // Common meeting apps
144
+ if (/zoom|meet|teams|slack|discord/i.test(app)) {
145
+ return `${app} call`;
146
+ }
147
+ return `Recording in ${app}`;
148
+ }
149
+
150
+ // Fallback with timestamp
151
+ const now = new Date();
152
+ return `Recording ${now.toLocaleDateString()} ${now.toLocaleTimeString()}`;
153
+ }
154
+
155
+ /**
156
+ * Build transcript from collected segments.
157
+ * Format: [MM:SS] text
158
+ */
159
+ private buildTranscript(): string {
160
+ if (this.segments.length === 0) return "";
161
+ if (!this.startedAt) return this.segments.map(s => s.text).join("\n");
162
+
163
+ const baseTs = this.startedAt;
164
+ return this.segments
165
+ .map(s => {
166
+ const offsetMs = s.ts - baseTs;
167
+ const offsetSec = Math.max(0, Math.floor(offsetMs / 1000));
168
+ const min = Math.floor(offsetSec / 60);
169
+ const sec = offsetSec % 60;
170
+ const timestamp = `[${String(min).padStart(2, "0")}:${String(sec).padStart(2, "0")}]`;
171
+ return `${timestamp} ${s.text}`;
172
+ })
173
+ .join("\n");
174
+ }
175
+
176
+ /**
177
+ * Force stop recording (e.g., on shutdown).
178
+ */
179
+ forceStop(): StopResult | undefined {
180
+ if (this.recording) {
181
+ warn(TAG, "force stopping recording");
182
+ return this.stop();
183
+ }
184
+ return undefined;
185
+ }
186
+ }
@@ -0,0 +1,456 @@
1
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
2
+ import { WebSocketServer, WebSocket } from "ws";
3
+ import type { CoreConfig, SenseEvent } from "./types.js";
4
+ import type { Profiler } from "./profiler.js";
5
+ import type { FeedbackStore } from "./learning/feedback-store.js";
6
+ import { FeedBuffer } from "./buffers/feed-buffer.js";
7
+ import { SenseBuffer, type SemanticSenseEvent, type TextDelta } from "./buffers/sense-buffer.js";
8
+ import { WsHandler } from "./overlay/ws-handler.js";
9
+ import { log, error } from "./log.js";
10
+
11
+ const TAG = "server";
12
+ const MAX_SENSE_BODY = 2 * 1024 * 1024;
13
+
14
+ /** Server epoch — lets clients detect restarts. */
15
+ const serverEpoch = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
16
+
17
+ export interface ServerDeps {
18
+ config: CoreConfig;
19
+ feedBuffer: FeedBuffer;
20
+ senseBuffer: SenseBuffer;
21
+ wsHandler: WsHandler;
22
+ profiler?: Profiler;
23
+ onSenseEvent: (event: SenseEvent) => void;
24
+ onSenseDelta?: (data: { app: string; activity: string; changes: TextDelta[]; priority?: string; ts: number }) => void;
25
+ onFeedPost: (text: string, priority: string) => void;
26
+ isScreenActive: () => boolean;
27
+ onSenseProfile: (snapshot: any) => void;
28
+ getHealthPayload: () => Record<string, unknown>;
29
+ getAgentDigest: () => unknown;
30
+ getAgentHistory: (limit: number) => unknown[];
31
+ getAgentContext: () => unknown;
32
+ getAgentConfig: () => unknown;
33
+ updateAgentConfig: (updates: Record<string, unknown>) => unknown;
34
+ getTraces: (after: number, limit: number) => unknown[];
35
+ reconnectGateway: () => void;
36
+ feedbackStore?: FeedbackStore;
37
+ getEscalationPending?: () => any;
38
+ respondEscalation?: (id: string, response: string) => any;
39
+ getKnowledgeDocPath?: () => string | null;
40
+ queryKnowledgeFacts?: (entities: string[], maxFacts: number) => Promise<string>;
41
+ }
42
+
43
+ function readBody(req: IncomingMessage, maxBytes: number): Promise<string> {
44
+ return new Promise((resolve, reject) => {
45
+ let body = "";
46
+ let bytes = 0;
47
+ req.on("data", (chunk: Buffer) => {
48
+ bytes += chunk.length;
49
+ if (bytes > maxBytes) {
50
+ reject(new Error("body too large"));
51
+ req.destroy();
52
+ return;
53
+ }
54
+ body += chunk;
55
+ });
56
+ req.on("end", () => resolve(body));
57
+ req.on("error", reject);
58
+ });
59
+ }
60
+
61
+ export function createAppServer(deps: ServerDeps) {
62
+ const { config, feedBuffer, senseBuffer, wsHandler } = deps;
63
+ let senseInBytes = 0;
64
+
65
+ const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => {
66
+ res.setHeader("Access-Control-Allow-Origin", "*");
67
+ res.setHeader("Content-Type", "application/json");
68
+
69
+ if (req.method === "OPTIONS") {
70
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
71
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
72
+ res.writeHead(204);
73
+ res.end();
74
+ return;
75
+ }
76
+
77
+ const url = new URL(req.url || "/", `http://localhost:${config.port}`);
78
+
79
+ try {
80
+ // ── /sense ──
81
+ if (req.method === "POST" && url.pathname === "/sense") {
82
+ if (!deps.isScreenActive()) {
83
+ res.end(JSON.stringify({ ok: true, gated: true }));
84
+ return;
85
+ }
86
+ const body = await readBody(req, MAX_SENSE_BODY);
87
+ senseInBytes += Buffer.byteLength(body);
88
+ deps.profiler?.gauge("network.senseInBytes", senseInBytes);
89
+ const data = JSON.parse(body);
90
+ if (!data.type || data.ts === undefined) {
91
+ res.writeHead(400);
92
+ res.end(JSON.stringify({ ok: false, error: "missing type or ts" }));
93
+ return;
94
+ }
95
+ // Extract image data from ROI if present
96
+ const imageData = data.roi?.data || undefined;
97
+ const imageBbox = data.roi?.bbox || undefined;
98
+
99
+ const event = senseBuffer.push({
100
+ type: data.type,
101
+ ts: data.ts,
102
+ ocr: data.ocr || "",
103
+ imageData,
104
+ imageBbox,
105
+ meta: {
106
+ ssim: data.meta?.ssim ?? 0,
107
+ app: data.meta?.app || "unknown",
108
+ windowTitle: data.meta?.windowTitle,
109
+ screen: data.meta?.screen ?? 0,
110
+ },
111
+ });
112
+ if (event) {
113
+ log(TAG, `[sense] #${event.id} (${event.type}): app=${event.meta.app} ssim=${event.meta.ssim?.toFixed(3)}`);
114
+ deps.onSenseEvent(event);
115
+ res.end(JSON.stringify({ ok: true, id: event.id }));
116
+ } else {
117
+ // Event was deduplicated
118
+ res.end(JSON.stringify({ ok: true, deduplicated: true }));
119
+ }
120
+ return;
121
+ }
122
+
123
+ if (req.method === "GET" && url.pathname === "/sense") {
124
+ const after = parseInt(url.searchParams.get("after") || "0");
125
+ const metaOnly = url.searchParams.get("meta_only") === "true";
126
+ const events = senseBuffer.query(after, metaOnly);
127
+ res.end(JSON.stringify({ events, epoch: serverEpoch }));
128
+ return;
129
+ }
130
+
131
+ // ── /sense/context (structured semantic context) ──
132
+ if (req.method === "GET" && url.pathname === "/sense/context") {
133
+ const limit = Math.min(parseInt(url.searchParams.get("limit") || "10"), 50);
134
+ const includeDeltas = url.searchParams.get("include_deltas") === "true";
135
+ const includeSummary = url.searchParams.get("include_summary") !== "false";
136
+ const context = senseBuffer.getStructuredContext({
137
+ limit,
138
+ includeDeltas,
139
+ includeSummary,
140
+ });
141
+ res.end(JSON.stringify({ ok: true, context, epoch: serverEpoch }));
142
+ return;
143
+ }
144
+
145
+ // ── /sense/activity (activity breakdown) ──
146
+ if (req.method === "GET" && url.pathname === "/sense/activity") {
147
+ const since = parseInt(url.searchParams.get("since") || "0");
148
+ const breakdown = senseBuffer.getActivityBreakdown(since);
149
+ res.end(JSON.stringify({
150
+ ok: true,
151
+ activity: senseBuffer.latestActivity(),
152
+ breakdown,
153
+ epoch: serverEpoch,
154
+ }));
155
+ return;
156
+ }
157
+
158
+ // ── /sense/deltas (accumulated deltas) ──
159
+ if (req.method === "GET" && url.pathname === "/sense/deltas") {
160
+ const flush = url.searchParams.get("flush") === "true";
161
+ const deltas = senseBuffer.getAccumulatedDeltas(flush);
162
+ res.end(JSON.stringify({ ok: true, deltas, count: deltas.length }));
163
+ return;
164
+ }
165
+
166
+ // ── /feed ──
167
+ if (req.method === "GET" && url.pathname === "/feed") {
168
+ const after = parseInt(url.searchParams.get("after") || "0");
169
+ const items = feedBuffer.query(after);
170
+ res.end(JSON.stringify({ messages: items, epoch: serverEpoch }));
171
+ return;
172
+ }
173
+
174
+ if (req.method === "POST" && url.pathname === "/feed") {
175
+ const body = await readBody(req, 65536);
176
+ const { text, priority } = JSON.parse(body);
177
+ deps.onFeedPost(text, priority || "normal");
178
+ res.end(JSON.stringify({ ok: true }));
179
+ return;
180
+ }
181
+
182
+ // ── /agent ──
183
+ if (req.method === "GET" && url.pathname === "/agent/digest") {
184
+ res.end(JSON.stringify({ ok: true, digest: deps.getAgentDigest() }));
185
+ return;
186
+ }
187
+
188
+ if (req.method === "GET" && url.pathname === "/agent/history") {
189
+ const limit = Math.min(parseInt(url.searchParams.get("limit") || "10"), 50);
190
+ res.end(JSON.stringify({ ok: true, results: deps.getAgentHistory(limit) }));
191
+ return;
192
+ }
193
+
194
+ if (req.method === "GET" && url.pathname === "/agent/context") {
195
+ res.end(JSON.stringify({ ok: true, context: deps.getAgentContext() }));
196
+ return;
197
+ }
198
+
199
+ if (req.method === "GET" && url.pathname === "/agent/config") {
200
+ res.end(JSON.stringify({ ok: true, config: deps.getAgentConfig() }));
201
+ return;
202
+ }
203
+
204
+ if (req.method === "POST" && url.pathname === "/agent/config") {
205
+ const body = await readBody(req, 4096);
206
+ const updates = JSON.parse(body);
207
+ const result = deps.updateAgentConfig(updates);
208
+ res.end(JSON.stringify({ ok: true, config: result }));
209
+ return;
210
+ }
211
+
212
+ // ── /knowledge ──
213
+ if (req.method === "GET" && url.pathname === "/knowledge") {
214
+ // Return portable knowledge document
215
+ const knowledgePath = deps.getKnowledgeDocPath?.();
216
+ if (knowledgePath) {
217
+ try {
218
+ const { readFileSync } = await import("node:fs");
219
+ const content = readFileSync(knowledgePath, "utf-8");
220
+ res.end(JSON.stringify({ ok: true, content }));
221
+ } catch {
222
+ res.end(JSON.stringify({ ok: true, content: "" }));
223
+ }
224
+ } else {
225
+ res.end(JSON.stringify({ ok: true, content: "" }));
226
+ }
227
+ return;
228
+ }
229
+
230
+ if (req.method === "GET" && url.pathname === "/knowledge/facts") {
231
+ // Query knowledge graph for entity-matched facts
232
+ const entitiesParam = url.searchParams.get("entities") || "";
233
+ const maxFacts = Math.min(parseInt(url.searchParams.get("max") || "5"), 20);
234
+ const entities = entitiesParam.split(",").map(e => e.trim()).filter(Boolean);
235
+
236
+ if (deps.queryKnowledgeFacts) {
237
+ try {
238
+ const facts = await deps.queryKnowledgeFacts(entities, maxFacts);
239
+ res.end(JSON.stringify({ ok: true, facts }));
240
+ } catch (err) {
241
+ res.end(JSON.stringify({ ok: true, facts: [], error: String(err) }));
242
+ }
243
+ } else {
244
+ res.end(JSON.stringify({ ok: true, facts: [] }));
245
+ }
246
+ return;
247
+ }
248
+
249
+ // ── /traces ──
250
+ if (req.method === "GET" && url.pathname === "/traces") {
251
+ const after = parseInt(url.searchParams.get("after") || "0");
252
+ const limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 500);
253
+ res.end(JSON.stringify({ traces: deps.getTraces(after, limit) }));
254
+ return;
255
+ }
256
+
257
+ // ── /profiling/sense ──
258
+ if (req.method === "POST" && url.pathname === "/profiling/sense") {
259
+ const body = await readBody(req, 4096);
260
+ deps.onSenseProfile(JSON.parse(body));
261
+ res.end(JSON.stringify({ ok: true }));
262
+ return;
263
+ }
264
+
265
+ // ── /learning/feedback ──
266
+ if (req.method === "GET" && url.pathname === "/learning/feedback") {
267
+ if (!deps.feedbackStore) {
268
+ res.end(JSON.stringify({ ok: false, error: "learning disabled" }));
269
+ return;
270
+ }
271
+ const limit = Math.min(parseInt(url.searchParams.get("limit") || "20"), 100);
272
+ const records = deps.feedbackStore.queryRecent(limit);
273
+ res.end(JSON.stringify({ ok: true, records, count: records.length }));
274
+ return;
275
+ }
276
+
277
+ // ── /learning/stats ──
278
+ if (req.method === "GET" && url.pathname === "/learning/stats") {
279
+ if (!deps.feedbackStore) {
280
+ res.end(JSON.stringify({ ok: false, error: "learning disabled" }));
281
+ return;
282
+ }
283
+ const stats = deps.feedbackStore.getStats();
284
+ res.end(JSON.stringify({ ok: true, ...stats }));
285
+ return;
286
+ }
287
+
288
+ // ── /reconnect-gateway ──
289
+ if (req.method === "POST" && url.pathname === "/reconnect-gateway") {
290
+ deps.reconnectGateway();
291
+ res.end(JSON.stringify({ ok: true, message: "gateway reconnection initiated" }));
292
+ return;
293
+ }
294
+
295
+ // ── /health ──
296
+ if (req.method === "GET" && url.pathname === "/health") {
297
+ res.end(JSON.stringify({
298
+ ok: true,
299
+ epoch: serverEpoch,
300
+ messages: feedBuffer.size,
301
+ senseEvents: senseBuffer.size,
302
+ overlayClients: wsHandler.clientCount,
303
+ ...deps.getHealthPayload(),
304
+ }));
305
+ return;
306
+ }
307
+
308
+ // ── /escalation/pending ──
309
+ if (req.method === "GET" && url.pathname === "/escalation/pending") {
310
+ const pending = deps.getEscalationPending?.();
311
+ res.end(JSON.stringify({ ok: true, escalation: pending ?? null }));
312
+ return;
313
+ }
314
+
315
+ // ── /escalation/respond ──
316
+ if (req.method === "POST" && url.pathname === "/escalation/respond") {
317
+ const body = await readBody(req, 65536);
318
+ const { id, response } = JSON.parse(body);
319
+ if (!id || !response) {
320
+ res.writeHead(400);
321
+ res.end(JSON.stringify({ ok: false, error: "missing id or response" }));
322
+ return;
323
+ }
324
+ const result = deps.respondEscalation?.(id, response) ?? { ok: false, error: "escalation not configured" };
325
+ res.end(JSON.stringify(result));
326
+ return;
327
+ }
328
+
329
+ res.writeHead(404);
330
+ res.end(JSON.stringify({ error: "not found" }));
331
+ } catch (err: any) {
332
+ const status = err.message === "body too large" ? 413 : 400;
333
+ res.writeHead(status);
334
+ res.end(JSON.stringify({ ok: false, error: err.message }));
335
+ }
336
+ });
337
+
338
+ // Attach WS server on the same HTTP server
339
+ const wss = new WebSocketServer({ server: httpServer });
340
+ wss.on("connection", (ws, req) => {
341
+ const pathname = new URL(req.url || "/", `http://localhost:${config.port}`).pathname;
342
+
343
+ // Sense WebSocket endpoint for low-latency event streaming
344
+ if (pathname === "/sense/ws") {
345
+ log(TAG, "[sense/ws] client connected");
346
+
347
+ // Backpressure tracking
348
+ let pendingAcks = 0;
349
+ const MAX_PENDING = 5;
350
+
351
+ ws.on("message", (data) => {
352
+ try {
353
+ const msg = JSON.parse(data.toString());
354
+
355
+ // Gate sense ingestion when screen is toggled off
356
+ if (!deps.isScreenActive()) {
357
+ ws.send(JSON.stringify({ type: "ack", gated: true }));
358
+ return;
359
+ }
360
+
361
+ // Handle different message types
362
+ if (msg.type === "delta") {
363
+ // Delta-only update (new semantic format)
364
+ senseBuffer.pushDelta({
365
+ app: msg.app || "unknown",
366
+ activity: msg.activity || "unknown",
367
+ changes: msg.changes || [],
368
+ priority: msg.priority,
369
+ ts: msg.ts || Date.now(),
370
+ });
371
+
372
+ // Trigger immediate context update for urgent priority
373
+ if (msg.priority === "urgent") {
374
+ deps.onSenseDelta?.(msg);
375
+ }
376
+
377
+ // Send ack with backpressure signal
378
+ pendingAcks++;
379
+ const backpressure = pendingAcks > MAX_PENDING ? 100 : 0;
380
+ ws.send(JSON.stringify({ type: "ack", backpressure }));
381
+ pendingAcks = Math.max(0, pendingAcks - 1);
382
+
383
+ } else {
384
+ // Full event (backwards compatible)
385
+ const imageData = msg.roi?.data || undefined;
386
+ const imageBbox = msg.roi?.bbox || undefined;
387
+
388
+ const event = senseBuffer.push({
389
+ type: msg.type,
390
+ ts: msg.ts,
391
+ ocr: msg.ocr || "",
392
+ imageData,
393
+ imageBbox,
394
+ meta: {
395
+ ssim: msg.meta?.ssim ?? 0,
396
+ app: msg.meta?.app || "unknown",
397
+ windowTitle: msg.meta?.windowTitle,
398
+ screen: msg.meta?.screen ?? 0,
399
+ },
400
+ semantic: msg.semantic,
401
+ priority: msg.priority,
402
+ });
403
+
404
+ if (event) {
405
+ deps.onSenseEvent(event);
406
+
407
+ // Send ack with event ID
408
+ pendingAcks++;
409
+ const backpressure = pendingAcks > MAX_PENDING ? 100 : 0;
410
+ ws.send(JSON.stringify({ type: "ack", id: event.id, backpressure }));
411
+ pendingAcks = Math.max(0, pendingAcks - 1);
412
+ } else {
413
+ // Deduplicated
414
+ ws.send(JSON.stringify({ type: "ack", deduplicated: true }));
415
+ }
416
+ }
417
+ } catch (err: any) {
418
+ ws.send(JSON.stringify({ type: "error", message: err.message }));
419
+ }
420
+ });
421
+
422
+ ws.on("close", () => {
423
+ log(TAG, "[sense/ws] client disconnected");
424
+ });
425
+
426
+ ws.on("error", (err) => {
427
+ error(TAG, `[sense/ws] error: ${err.message}`);
428
+ });
429
+
430
+ return;
431
+ }
432
+
433
+ // Default: overlay WebSocket handler
434
+ wsHandler.handleConnection(ws, req);
435
+ });
436
+
437
+ return {
438
+ httpServer,
439
+ wss,
440
+ start(): Promise<void> {
441
+ return new Promise((resolve, reject) => {
442
+ httpServer.on("error", reject);
443
+ httpServer.listen(config.port, "0.0.0.0", () => {
444
+ log(TAG, `listening on http://0.0.0.0:${config.port} (HTTP + WS, epoch=${serverEpoch})`);
445
+ resolve();
446
+ });
447
+ });
448
+ },
449
+ async destroy(): Promise<void> {
450
+ wsHandler.destroy();
451
+ wss.close();
452
+ await new Promise<void>((resolve) => httpServer.close(() => resolve()));
453
+ log(TAG, "server closed");
454
+ },
455
+ };
456
+ }