@geravant/sinain 1.0.19 → 1.1.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 (73) hide show
  1. package/README.md +10 -1
  2. package/cli.js +176 -0
  3. package/install.js +11 -2
  4. package/launcher.js +622 -0
  5. package/openclaw.plugin.json +4 -0
  6. package/pack-prepare.js +48 -0
  7. package/package.json +24 -5
  8. package/sense_client/README.md +82 -0
  9. package/sense_client/__init__.py +1 -0
  10. package/sense_client/__main__.py +462 -0
  11. package/sense_client/app_detector.py +54 -0
  12. package/sense_client/app_detector_win.py +83 -0
  13. package/sense_client/capture.py +215 -0
  14. package/sense_client/capture_win.py +88 -0
  15. package/sense_client/change_detector.py +86 -0
  16. package/sense_client/config.py +64 -0
  17. package/sense_client/gate.py +145 -0
  18. package/sense_client/ocr.py +347 -0
  19. package/sense_client/privacy.py +65 -0
  20. package/sense_client/requirements.txt +13 -0
  21. package/sense_client/roi_extractor.py +84 -0
  22. package/sense_client/sender.py +173 -0
  23. package/sense_client/tests/__init__.py +0 -0
  24. package/sense_client/tests/test_stream1_optimizations.py +234 -0
  25. package/setup-overlay.js +82 -0
  26. package/sinain-agent/.env.example +17 -0
  27. package/sinain-agent/CLAUDE.md +80 -0
  28. package/sinain-agent/mcp-config.json +12 -0
  29. package/sinain-agent/run.sh +248 -0
  30. package/sinain-core/.env.example +93 -0
  31. package/sinain-core/package-lock.json +552 -0
  32. package/sinain-core/package.json +21 -0
  33. package/sinain-core/src/agent/analyzer.ts +366 -0
  34. package/sinain-core/src/agent/context-window.ts +172 -0
  35. package/sinain-core/src/agent/loop.ts +404 -0
  36. package/sinain-core/src/agent/situation-writer.ts +187 -0
  37. package/sinain-core/src/agent/traits.ts +520 -0
  38. package/sinain-core/src/audio/capture-spawner-macos.ts +44 -0
  39. package/sinain-core/src/audio/capture-spawner-win.ts +37 -0
  40. package/sinain-core/src/audio/capture-spawner.ts +14 -0
  41. package/sinain-core/src/audio/pipeline.ts +335 -0
  42. package/sinain-core/src/audio/transcription-local.ts +141 -0
  43. package/sinain-core/src/audio/transcription.ts +278 -0
  44. package/sinain-core/src/buffers/feed-buffer.ts +71 -0
  45. package/sinain-core/src/buffers/sense-buffer.ts +425 -0
  46. package/sinain-core/src/config.ts +245 -0
  47. package/sinain-core/src/escalation/escalation-slot.ts +136 -0
  48. package/sinain-core/src/escalation/escalator.ts +812 -0
  49. package/sinain-core/src/escalation/message-builder.ts +323 -0
  50. package/sinain-core/src/escalation/openclaw-ws.ts +726 -0
  51. package/sinain-core/src/escalation/scorer.ts +166 -0
  52. package/sinain-core/src/index.ts +507 -0
  53. package/sinain-core/src/learning/feedback-store.ts +253 -0
  54. package/sinain-core/src/learning/signal-collector.ts +218 -0
  55. package/sinain-core/src/log.ts +24 -0
  56. package/sinain-core/src/overlay/commands.ts +126 -0
  57. package/sinain-core/src/overlay/ws-handler.ts +267 -0
  58. package/sinain-core/src/privacy/index.ts +18 -0
  59. package/sinain-core/src/privacy/presets.ts +40 -0
  60. package/sinain-core/src/privacy/redact.ts +92 -0
  61. package/sinain-core/src/profiler.ts +181 -0
  62. package/sinain-core/src/recorder.ts +186 -0
  63. package/sinain-core/src/server.ts +417 -0
  64. package/sinain-core/src/trace/trace-store.ts +73 -0
  65. package/sinain-core/src/trace/tracer.ts +94 -0
  66. package/sinain-core/src/types.ts +427 -0
  67. package/sinain-core/src/util/dedup.ts +48 -0
  68. package/sinain-core/src/util/task-store.ts +84 -0
  69. package/sinain-core/tsconfig.json +18 -0
  70. package/sinain-knowledge/data/git-store.ts +2 -0
  71. package/sinain-mcp-server/index.ts +337 -0
  72. package/sinain-mcp-server/package.json +19 -0
  73. package/sinain-mcp-server/tsconfig.json +15 -0
@@ -0,0 +1,267 @@
1
+ import { WebSocket } from "ws";
2
+ import type { IncomingMessage } from "node:http";
3
+ import type {
4
+ BridgeState,
5
+ OutboundMessage,
6
+ InboundMessage,
7
+ FeedMessage,
8
+ StatusMessage,
9
+ SpawnTaskMessage,
10
+ Priority,
11
+ FeedChannel,
12
+ } from "../types.js";
13
+ import { log, warn } from "../log.js";
14
+
15
+ const TAG = "ws";
16
+ const HEARTBEAT_INTERVAL_MS = 10_000;
17
+ const MAX_REPLAY = 20;
18
+ const SPAWN_TASK_TTL_MS = 120_000; // prune terminal tasks after 120s
19
+
20
+ type MessageHandler = (msg: InboundMessage, client: WebSocket) => void;
21
+ type ProfilingHandler = (msg: any) => void;
22
+
23
+ /**
24
+ * WebSocket handler for overlay connections.
25
+ * Manages connected clients, heartbeat pings, replay buffer, and message routing.
26
+ * Ported from bridge/ws-server.ts — now runs on the same port as HTTP via the shared http.Server.
27
+ */
28
+ export class WsHandler {
29
+ private clients: Set<WebSocket> = new Set();
30
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
31
+ private onMessage: MessageHandler | null = null;
32
+ private onProfilingCb: ProfilingHandler | null = null;
33
+ private state: BridgeState = {
34
+ audio: "muted",
35
+ mic: "muted",
36
+ screen: "off",
37
+ connection: "disconnected",
38
+ };
39
+ private replayBuffer: FeedMessage[] = [];
40
+ private spawnTaskBuffer: Map<string, SpawnTaskMessage> = new Map();
41
+
42
+ constructor() {
43
+ this.startHeartbeat();
44
+ }
45
+
46
+ /** Register handler for incoming overlay messages. */
47
+ onIncoming(handler: MessageHandler): void {
48
+ this.onMessage = handler;
49
+ }
50
+
51
+ /** Register handler for profiling messages from overlay. */
52
+ onProfiling(handler: ProfilingHandler): void {
53
+ this.onProfilingCb = handler;
54
+ }
55
+
56
+ /** Handle a new WS connection (called from server.ts wss.on('connection')). */
57
+ handleConnection(ws: WebSocket, req: IncomingMessage): void {
58
+ const addr = req.socket.remoteAddress ?? "unknown";
59
+ log(TAG, `client connected from ${addr}`);
60
+ this.clients.add(ws);
61
+ this.updateConnection("connected");
62
+
63
+ (ws as any).__alive = true;
64
+
65
+ // Send current status on connect
66
+ this.sendTo(ws, {
67
+ type: "status",
68
+ audio: this.state.audio,
69
+ mic: this.state.mic,
70
+ screen: this.state.screen,
71
+ connection: this.state.connection,
72
+ });
73
+
74
+ // Replay recent feed messages for late-joining clients
75
+ for (const msg of this.replayBuffer) {
76
+ this.sendTo(ws, msg);
77
+ }
78
+ if (this.replayBuffer.length > 0) {
79
+ log(TAG, `replayed ${this.replayBuffer.length} buffered messages to new client`);
80
+ }
81
+
82
+ // Replay spawn task state for late-joining clients
83
+ this.pruneSpawnTasks();
84
+ for (const msg of this.spawnTaskBuffer.values()) {
85
+ this.sendTo(ws, msg);
86
+ }
87
+ if (this.spawnTaskBuffer.size > 0) {
88
+ log(TAG, `replayed ${this.spawnTaskBuffer.size} spawn tasks to new client`);
89
+ }
90
+
91
+ ws.on("message", (raw) => {
92
+ try {
93
+ const data = JSON.parse(raw.toString()) as InboundMessage;
94
+ this.handleIncoming(data, ws);
95
+ } catch {
96
+ warn(TAG, "bad message from client:", raw.toString().slice(0, 200));
97
+ }
98
+ });
99
+
100
+ ws.on("pong", () => {
101
+ (ws as any).__alive = true;
102
+ });
103
+
104
+ ws.on("close", (code, reason) => {
105
+ log(TAG, `client disconnected: ${code} ${reason?.toString() ?? ""}`);
106
+ this.clients.delete(ws);
107
+ ws.removeAllListeners();
108
+ if (this.clients.size === 0) {
109
+ this.updateConnection("disconnected");
110
+ }
111
+ });
112
+
113
+ ws.on("error", (err) => {
114
+ warn(TAG, "client error:", err.message);
115
+ this.clients.delete(ws);
116
+ ws.removeAllListeners();
117
+ });
118
+ }
119
+
120
+ /** Broadcast a feed message to all connected overlays. */
121
+ broadcast(text: string, priority: Priority = "normal", channel: FeedChannel = "stream"): void {
122
+ const msg: FeedMessage = {
123
+ type: "feed",
124
+ text,
125
+ priority,
126
+ ts: Date.now(),
127
+ channel,
128
+ };
129
+ this.replayBuffer.push(msg);
130
+ if (this.replayBuffer.length > MAX_REPLAY) {
131
+ this.replayBuffer.shift();
132
+ }
133
+ this.broadcastMessage(msg);
134
+ }
135
+
136
+ /** Send a status update to all connected overlays. */
137
+ broadcastStatus(): void {
138
+ const msg: StatusMessage = {
139
+ type: "status",
140
+ audio: this.state.audio,
141
+ mic: this.state.mic,
142
+ screen: this.state.screen,
143
+ connection: this.state.connection,
144
+ };
145
+ this.broadcastMessage(msg);
146
+ }
147
+
148
+ /** Broadcast any outbound message (used by escalator for spawn_task events). */
149
+ broadcastRaw(msg: OutboundMessage): void {
150
+ if (msg.type === "spawn_task") {
151
+ const taskMsg = msg as SpawnTaskMessage;
152
+ this.spawnTaskBuffer.set(taskMsg.taskId, taskMsg);
153
+ this.pruneSpawnTasks();
154
+ log(TAG, `spawn_task buffered: taskId=${taskMsg.taskId}, status=${taskMsg.status}, buffer=${this.spawnTaskBuffer.size}, clients=${this.clients.size}`);
155
+ }
156
+ this.broadcastMessage(msg);
157
+ }
158
+
159
+ /** Update internal state and broadcast. */
160
+ updateState(partial: Partial<BridgeState>): void {
161
+ Object.assign(this.state, partial);
162
+ this.broadcastStatus();
163
+ }
164
+
165
+ /** Get current state. */
166
+ getState(): Readonly<BridgeState> {
167
+ return { ...this.state };
168
+ }
169
+
170
+ /** Number of connected clients. */
171
+ get clientCount(): number {
172
+ return this.clients.size;
173
+ }
174
+
175
+ /** Graceful shutdown. */
176
+ destroy(): void {
177
+ this.stopHeartbeat();
178
+ for (const ws of this.clients) {
179
+ ws.close(1001, "server shutting down");
180
+ }
181
+ this.clients.clear();
182
+ }
183
+
184
+ // ── Private ──
185
+
186
+ private handleIncoming(msg: InboundMessage, ws: WebSocket): void {
187
+ switch (msg.type) {
188
+ case "pong":
189
+ (ws as any).__alive = true;
190
+ return;
191
+ case "message":
192
+ log(TAG, `\u2190 user message: ${msg.text.slice(0, 100)}`);
193
+ break;
194
+ case "command":
195
+ log(TAG, `\u2190 command: ${msg.action}`);
196
+ break;
197
+ case "profiling":
198
+ if (this.onProfilingCb) this.onProfilingCb(msg);
199
+ return;
200
+ default:
201
+ warn(TAG, `unknown message type: ${(msg as any).type}`);
202
+ return;
203
+ }
204
+ if (this.onMessage) {
205
+ this.onMessage(msg, ws);
206
+ }
207
+ }
208
+
209
+ private sendTo(ws: WebSocket, msg: OutboundMessage): void {
210
+ if (ws.readyState === WebSocket.OPEN) {
211
+ ws.send(JSON.stringify(msg));
212
+ }
213
+ }
214
+
215
+ private broadcastMessage(msg: OutboundMessage): void {
216
+ const payload = JSON.stringify(msg);
217
+ for (const ws of this.clients) {
218
+ if (ws.readyState === WebSocket.OPEN) {
219
+ ws.send(payload);
220
+ }
221
+ }
222
+ }
223
+
224
+ private pruneSpawnTasks(): void {
225
+ const now = Date.now();
226
+ const terminal = new Set(["completed", "failed", "timeout"]);
227
+ for (const [id, msg] of this.spawnTaskBuffer) {
228
+ if (terminal.has(msg.status) && msg.completedAt && now - msg.completedAt > SPAWN_TASK_TTL_MS) {
229
+ this.spawnTaskBuffer.delete(id);
230
+ }
231
+ }
232
+ }
233
+
234
+ private updateConnection(status: BridgeState["connection"]): void {
235
+ this.state.connection = status;
236
+ if (this.clients.size > 0) {
237
+ this.broadcastStatus();
238
+ }
239
+ }
240
+
241
+ private startHeartbeat(): void {
242
+ this.heartbeatTimer = setInterval(() => {
243
+ for (const ws of this.clients) {
244
+ if ((ws as any).__alive === false) {
245
+ log(TAG, "client failed heartbeat \u2014 closing");
246
+ ws.close(4000, "heartbeat timeout");
247
+ this.clients.delete(ws);
248
+ if (this.clients.size === 0) {
249
+ this.updateConnection("disconnected");
250
+ }
251
+ continue;
252
+ }
253
+ (ws as any).__alive = false;
254
+ ws.ping();
255
+ // App-level ping for Flutter clients that don't handle protocol pings
256
+ this.sendTo(ws, { type: "ping", ts: Date.now() });
257
+ }
258
+ }, HEARTBEAT_INTERVAL_MS);
259
+ }
260
+
261
+ private stopHeartbeat(): void {
262
+ if (this.heartbeatTimer) {
263
+ clearInterval(this.heartbeatTimer);
264
+ this.heartbeatTimer = null;
265
+ }
266
+ }
267
+ }
@@ -0,0 +1,18 @@
1
+ import type { PrivacyConfig, PrivacyMatrix, PrivacyDest, PrivacyLevel } from "../types.js";
2
+
3
+ let _privacy: PrivacyConfig | undefined;
4
+
5
+ export function initPrivacy(cfg: PrivacyConfig): void {
6
+ _privacy = cfg;
7
+ }
8
+
9
+ export function getPrivacy(): PrivacyConfig {
10
+ if (!_privacy) throw new Error("Privacy not initialized — call initPrivacy() first");
11
+ return _privacy;
12
+ }
13
+
14
+ export function levelFor(dataType: keyof PrivacyMatrix, dest: PrivacyDest): PrivacyLevel {
15
+ return getPrivacy().matrix[dataType][dest];
16
+ }
17
+
18
+ export { applyLevel, redactText, summarizeAudio, summarizeOcr } from "./redact.js";
@@ -0,0 +1,40 @@
1
+ import type { PrivacyMatrix } from "../types.js";
2
+
3
+ const FULL_ROW = { local_buffer: "full" as const, local_llm: "full" as const, triple_store: "full" as const, openrouter: "full" as const, agent_gateway: "full" as const };
4
+
5
+ const ALL_FULL: PrivacyMatrix = {
6
+ audio_transcript: { ...FULL_ROW },
7
+ screen_ocr: { ...FULL_ROW },
8
+ screen_images: { ...FULL_ROW },
9
+ window_titles: { ...FULL_ROW },
10
+ credentials: { ...FULL_ROW },
11
+ metadata: { ...FULL_ROW },
12
+ };
13
+
14
+ export const PRESETS: Record<string, PrivacyMatrix> = {
15
+ off: ALL_FULL,
16
+ standard: {
17
+ audio_transcript: { local_buffer: "full", local_llm: "redacted", triple_store: "redacted", openrouter: "redacted", agent_gateway: "redacted" },
18
+ screen_ocr: { local_buffer: "redacted", local_llm: "redacted", triple_store: "redacted", openrouter: "redacted", agent_gateway: "redacted" },
19
+ screen_images: { local_buffer: "full", local_llm: "none", triple_store: "none", openrouter: "none", agent_gateway: "none" },
20
+ window_titles: { local_buffer: "full", local_llm: "summary", triple_store: "summary", openrouter: "summary", agent_gateway: "none" },
21
+ credentials: { local_buffer: "none", local_llm: "none", triple_store: "none", openrouter: "none", agent_gateway: "none" },
22
+ metadata: { local_buffer: "full", local_llm: "full", triple_store: "full", openrouter: "summary", agent_gateway: "summary" },
23
+ },
24
+ strict: {
25
+ audio_transcript: { local_buffer: "redacted", local_llm: "summary", triple_store: "summary", openrouter: "summary", agent_gateway: "none" },
26
+ screen_ocr: { local_buffer: "redacted", local_llm: "summary", triple_store: "none", openrouter: "summary", agent_gateway: "none" },
27
+ screen_images: { local_buffer: "none", local_llm: "none", triple_store: "none", openrouter: "none", agent_gateway: "none" },
28
+ window_titles: { local_buffer: "summary", local_llm: "summary", triple_store: "none", openrouter: "none", agent_gateway: "none" },
29
+ credentials: { local_buffer: "none", local_llm: "none", triple_store: "none", openrouter: "none", agent_gateway: "none" },
30
+ metadata: { local_buffer: "full", local_llm: "summary", triple_store: "summary", openrouter: "none", agent_gateway: "none" },
31
+ },
32
+ paranoid: {
33
+ audio_transcript: { local_buffer: "redacted", local_llm: "none", triple_store: "none", openrouter: "none", agent_gateway: "none" },
34
+ screen_ocr: { local_buffer: "redacted", local_llm: "none", triple_store: "none", openrouter: "none", agent_gateway: "none" },
35
+ screen_images: { local_buffer: "none", local_llm: "none", triple_store: "none", openrouter: "none", agent_gateway: "none" },
36
+ window_titles: { local_buffer: "none", local_llm: "none", triple_store: "none", openrouter: "none", agent_gateway: "none" },
37
+ credentials: { local_buffer: "none", local_llm: "none", triple_store: "none", openrouter: "none", agent_gateway: "none" },
38
+ metadata: { local_buffer: "summary", local_llm: "none", triple_store: "none", openrouter: "none", agent_gateway: "none" },
39
+ },
40
+ };
@@ -0,0 +1,92 @@
1
+ import type { PrivacyLevel } from "../types.js";
2
+
3
+ // ── Pattern library ──────────────────────────────────────────────────────────
4
+
5
+ // AUTH_CREDENTIALS
6
+ const AUTH_CREDENTIALS = [
7
+ /\bpassword\s*[:=]\s*\S+/gi,
8
+ /\bpasswd\s*[:=]\s*\S+/gi,
9
+ /\bsecret\s*[:=]\s*\S+/gi,
10
+ /\bpwd\s*[:=]\s*\S+/gi,
11
+ /\bpin\s*[:=]\s*\d{4,8}\b/gi,
12
+ ];
13
+
14
+ // API_TOKENS
15
+ const API_TOKENS = [
16
+ /\bBearer\s+[A-Za-z0-9\-._~+/]+=*/gi,
17
+ /\bsk-[A-Za-z0-9]{20,}/g,
18
+ /\bghp_[A-Za-z0-9]{36}/g,
19
+ /\bghs_[A-Za-z0-9]{36}/g,
20
+ /\b(AKIA|ASIA)[A-Z0-9]{16}\b/g,
21
+ /\bxox[bpoa]-[0-9A-Za-z-]+/g,
22
+ /\bya29\.[0-9A-Za-z-_]+/g,
23
+ /\beyJ[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+/g,
24
+ /\bapi[_-]?key\s*[:=]\s*\S+/gi,
25
+ ];
26
+
27
+ // FINANCIAL
28
+ const FINANCIAL = [
29
+ // Luhn-matching card numbers (16 digits with optional separators)
30
+ /\b(?:\d{4}[-\s]?){3}\d{4}\b/g,
31
+ /\bCVV\s*[:=]?\s*\d{3,4}\b/gi,
32
+ /\bIBAN\s*[:=]?\s*[A-Z]{2}\d{2}[\dA-Z]{4,30}\b/gi,
33
+ /\b\d{3}-\d{2}-\d{4}\b/g, // SSN
34
+ ];
35
+
36
+ // PII_CONTACT
37
+ const PII_CONTACT = [
38
+ /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
39
+ /\+?1?\s?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}\b/g, // US phone
40
+ /\+\d{1,3}\s\d{1,4}[\s.-]\d{3,4}[\s.-]\d{4,9}/g, // international phone
41
+ ];
42
+
43
+ // HEALTH
44
+ const HEALTH = [
45
+ /\bMRN\s*[:=]?\s*\d{6,10}\b/gi,
46
+ /\b(diagnosis|prescription|patient ID)\s*[:=]\s*\S+/gi,
47
+ ];
48
+
49
+ const ALL_PATTERNS: RegExp[] = [
50
+ ...AUTH_CREDENTIALS,
51
+ ...API_TOKENS,
52
+ ...FINANCIAL,
53
+ ...PII_CONTACT,
54
+ ...HEALTH,
55
+ ];
56
+
57
+ export function redactText(text: string): string {
58
+ let result = text;
59
+ for (const pattern of ALL_PATTERNS) {
60
+ result = result.replace(pattern, "[REDACTED]");
61
+ }
62
+ return result;
63
+ }
64
+
65
+ export function summarizeAudio(text: string): string {
66
+ const wordCount = text.trim().split(/\s+/).filter(Boolean).length;
67
+ return `[AUDIO: ${wordCount} words]`;
68
+ }
69
+
70
+ export function summarizeOcr(text: string): string {
71
+ return `[SCREEN: ${text.length} chars]`;
72
+ }
73
+
74
+ export function applyLevel(
75
+ text: string,
76
+ level: PrivacyLevel,
77
+ dataType: "audio" | "ocr" | "titles" = "ocr"
78
+ ): string {
79
+ switch (level) {
80
+ case "full":
81
+ return text;
82
+ case "redacted":
83
+ return redactText(text);
84
+ case "summary":
85
+ if (dataType === "audio") return summarizeAudio(text);
86
+ return summarizeOcr(text);
87
+ case "none":
88
+ return "";
89
+ default:
90
+ return text;
91
+ }
92
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Central metrics collector for all HUD processes.
3
+ * Subsystems report gauges and timers; external processes (sense, overlay)
4
+ * push snapshots via HTTP/WS. `/health` returns everything in one call.
5
+ */
6
+
7
+ import { monitorEventLoopDelay, PerformanceObserver } from "node:perf_hooks";
8
+ import type { IntervalHistogram } from "node:perf_hooks";
9
+
10
+ export interface ProcessSnapshot {
11
+ rssMb: number;
12
+ heapUsedMb?: number;
13
+ heapTotalMb?: number;
14
+ cpuUserMs?: number;
15
+ cpuSystemMs?: number;
16
+ uptimeS: number;
17
+ ts: number;
18
+ extra?: Record<string, number>;
19
+ }
20
+
21
+ interface TimerStats {
22
+ count: number;
23
+ totalMs: number;
24
+ lastMs: number;
25
+ maxMs: number;
26
+ }
27
+
28
+ export interface ProfilingSnapshot {
29
+ core: ProcessSnapshot & {
30
+ gauges: Record<string, number>;
31
+ timers: Record<string, TimerStats>;
32
+ };
33
+ sense: ProcessSnapshot | null;
34
+ overlay: ProcessSnapshot | null;
35
+ sampledAt: number;
36
+ }
37
+
38
+ export class Profiler {
39
+ private gauges: Record<string, number> = {};
40
+ private timers: Record<string, TimerStats> = {};
41
+ private coreSnapshot: ProcessSnapshot | null = null;
42
+ private senseSnapshot: ProcessSnapshot | null = null;
43
+ private overlaySnapshot: ProcessSnapshot | null = null;
44
+ private interval: ReturnType<typeof setInterval> | null = null;
45
+ private startTs = Date.now();
46
+
47
+ // Event loop lag tracking
48
+ private elHistogram: IntervalHistogram | null = null;
49
+ private elMaxLagMs = 0;
50
+
51
+ // GC pause tracking
52
+ private gcObserver: PerformanceObserver | null = null;
53
+ private gcStats = { totalPauseMs: 0, count: 0, lastPauseMs: 0, maxPauseMs: 0 };
54
+
55
+ /** Set a named gauge value. */
56
+ gauge(name: string, value: number): void {
57
+ this.gauges[name] = value;
58
+ }
59
+
60
+ /** Record a timing measurement. */
61
+ timerRecord(name: string, durationMs: number): void {
62
+ const existing = this.timers[name];
63
+ if (existing) {
64
+ existing.count++;
65
+ existing.totalMs += durationMs;
66
+ existing.lastMs = durationMs;
67
+ if (durationMs > existing.maxMs) existing.maxMs = durationMs;
68
+ } else {
69
+ this.timers[name] = { count: 1, totalMs: durationMs, lastMs: durationMs, maxMs: durationMs };
70
+ }
71
+ }
72
+
73
+ /** Wrap an async call with automatic timing. */
74
+ async timeAsync<T>(name: string, fn: () => Promise<T>): Promise<T> {
75
+ const start = Date.now();
76
+ try {
77
+ return await fn();
78
+ } finally {
79
+ this.timerRecord(name, Date.now() - start);
80
+ }
81
+ }
82
+
83
+ /** Store the latest sense_client process snapshot. */
84
+ reportSense(snapshot: ProcessSnapshot): void {
85
+ this.senseSnapshot = snapshot;
86
+ }
87
+
88
+ /** Store the latest overlay process snapshot. */
89
+ reportOverlay(snapshot: ProcessSnapshot): void {
90
+ this.overlaySnapshot = snapshot;
91
+ }
92
+
93
+ /** Returns the full profiling payload for /health. */
94
+ getSnapshot(): ProfilingSnapshot {
95
+ return {
96
+ core: {
97
+ ...(this.coreSnapshot ?? this.sampleCore()),
98
+ gauges: { ...this.gauges },
99
+ timers: { ...this.timers },
100
+ },
101
+ sense: this.senseSnapshot,
102
+ overlay: this.overlaySnapshot,
103
+ sampledAt: Date.now(),
104
+ };
105
+ }
106
+
107
+ /** Start periodic core process sampling (every 10s). */
108
+ start(): void {
109
+ this.startTs = Date.now();
110
+ this.sampleCore();
111
+ this.interval = setInterval(() => this.sampleCore(), 10_000);
112
+
113
+ // Event loop lag histogram (20ms resolution)
114
+ this.elHistogram = monitorEventLoopDelay({ resolution: 20 });
115
+ this.elHistogram.enable();
116
+
117
+ // GC pause observer
118
+ try {
119
+ this.gcObserver = new PerformanceObserver((list) => {
120
+ for (const entry of list.getEntries()) {
121
+ const pauseMs = entry.duration;
122
+ this.gcStats.count++;
123
+ this.gcStats.totalPauseMs += pauseMs;
124
+ this.gcStats.lastPauseMs = pauseMs;
125
+ if (pauseMs > this.gcStats.maxPauseMs) this.gcStats.maxPauseMs = pauseMs;
126
+ }
127
+ });
128
+ this.gcObserver.observe({ entryTypes: ["gc"] });
129
+ } catch {
130
+ // GC observation may not be available in all environments
131
+ }
132
+ }
133
+
134
+ /** Stop periodic sampling. */
135
+ stop(): void {
136
+ if (this.interval) {
137
+ clearInterval(this.interval);
138
+ this.interval = null;
139
+ }
140
+ if (this.elHistogram) {
141
+ this.elHistogram.disable();
142
+ this.elHistogram = null;
143
+ }
144
+ if (this.gcObserver) {
145
+ this.gcObserver.disconnect();
146
+ this.gcObserver = null;
147
+ }
148
+ }
149
+
150
+ private sampleCore(): ProcessSnapshot {
151
+ const mem = process.memoryUsage();
152
+ const cpu = process.cpuUsage();
153
+ const snap: ProcessSnapshot = {
154
+ rssMb: Math.round((mem.rss / 1048576) * 10) / 10,
155
+ heapUsedMb: Math.round((mem.heapUsed / 1048576) * 10) / 10,
156
+ heapTotalMb: Math.round((mem.heapTotal / 1048576) * 10) / 10,
157
+ cpuUserMs: Math.round(cpu.user / 1000),
158
+ cpuSystemMs: Math.round(cpu.system / 1000),
159
+ uptimeS: Math.round((Date.now() - this.startTs) / 1000),
160
+ ts: Date.now(),
161
+ };
162
+ this.coreSnapshot = snap;
163
+
164
+ // Event loop lag gauges
165
+ if (this.elHistogram) {
166
+ const meanLagMs = this.elHistogram.mean / 1e6; // ns → ms
167
+ if (meanLagMs > this.elMaxLagMs) this.elMaxLagMs = meanLagMs;
168
+ this.gauges["eventLoop.lagMs"] = Math.round(meanLagMs * 100) / 100;
169
+ this.gauges["eventLoop.maxLagMs"] = Math.round(this.elMaxLagMs * 100) / 100;
170
+ this.elHistogram.reset();
171
+ }
172
+
173
+ // GC gauges
174
+ this.gauges["gc.totalPauseMs"] = Math.round(this.gcStats.totalPauseMs * 100) / 100;
175
+ this.gauges["gc.count"] = this.gcStats.count;
176
+ this.gauges["gc.lastPauseMs"] = Math.round(this.gcStats.lastPauseMs * 100) / 100;
177
+ this.gauges["gc.maxPauseMs"] = Math.round(this.gcStats.maxPauseMs * 100) / 100;
178
+
179
+ return snap;
180
+ }
181
+ }