@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,253 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+ import type { FeedbackRecord, FeedbackSignals } from "../types.js";
5
+ import { log, error } from "../log.js";
6
+
7
+ const TAG = "feedback-store";
8
+
9
+ /**
10
+ * Persistent JSONL feedback log — one file per day.
11
+ * Follows TraceStore pattern: daily rotation, WriteStream append.
12
+ *
13
+ * Storage: ~/.sinain-core/feedback/2025-02-03.jsonl
14
+ *
15
+ * Records are written at escalation time with null signals, then
16
+ * patched in-place by SignalCollector once deferred feedback arrives.
17
+ */
18
+ export class FeedbackStore {
19
+ private dir: string;
20
+ private currentDate = "";
21
+ private currentStream: fs.WriteStream | null = null;
22
+ private retentionDays: number;
23
+
24
+ constructor(dir: string, retentionDays = 30) {
25
+ this.dir = dir;
26
+ this.retentionDays = retentionDays;
27
+ try {
28
+ fs.mkdirSync(dir, { recursive: true });
29
+ } catch (err: any) {
30
+ if (err.code !== "EEXIST") {
31
+ error(TAG, "failed to create feedback dir:", err.message);
32
+ }
33
+ }
34
+ }
35
+
36
+ /** Create a new FeedbackRecord with null signals. */
37
+ createRecord(params: {
38
+ tickId: number;
39
+ digest: string;
40
+ hud: string;
41
+ currentApp: string;
42
+ escalationScore: number;
43
+ escalationReasons: string[];
44
+ codingContext: boolean;
45
+ escalationMessage: string;
46
+ openclawResponse: string;
47
+ responseLatencyMs: number;
48
+ }): FeedbackRecord {
49
+ const record: FeedbackRecord = {
50
+ id: crypto.randomUUID(),
51
+ ts: Date.now(),
52
+ tickId: params.tickId,
53
+ digest: params.digest.slice(0, 2048),
54
+ hud: params.hud,
55
+ currentApp: params.currentApp,
56
+ escalationScore: params.escalationScore,
57
+ escalationReasons: params.escalationReasons,
58
+ codingContext: params.codingContext,
59
+ escalationMessage: params.escalationMessage.slice(0, 2048),
60
+ openclawResponse: params.openclawResponse.slice(0, 2048),
61
+ responseLatencyMs: params.responseLatencyMs,
62
+ signals: {
63
+ errorCleared: null,
64
+ noReEscalation: null,
65
+ dwellTimeMs: null,
66
+ quickAppSwitch: null,
67
+ compositeScore: 0,
68
+ },
69
+ tags: this.deriveTags(params),
70
+ };
71
+ return record;
72
+ }
73
+
74
+ /** Append a record to today's JSONL file. */
75
+ append(record: FeedbackRecord): void {
76
+ try {
77
+ this.rotateIfNeeded();
78
+ if (this.currentStream) {
79
+ this.currentStream.write(JSON.stringify(record) + "\n");
80
+ }
81
+ } catch (err: any) {
82
+ error(TAG, "append failed:", err.message);
83
+ }
84
+ }
85
+
86
+ /** Update signals for a record by ID. Reads + rewrites the day's file. */
87
+ updateSignals(recordId: string, date: string, signals: FeedbackSignals): boolean {
88
+ const filePath = path.join(this.dir, `${date}.jsonl`);
89
+ try {
90
+ const content = fs.readFileSync(filePath, "utf-8");
91
+ const lines = content.split("\n");
92
+ let updated = false;
93
+
94
+ const newLines = lines.map(line => {
95
+ if (!line.trim()) return line;
96
+ try {
97
+ const rec = JSON.parse(line) as FeedbackRecord;
98
+ if (rec.id === recordId) {
99
+ rec.signals = signals;
100
+ updated = true;
101
+ return JSON.stringify(rec);
102
+ }
103
+ } catch { /* skip malformed lines */ }
104
+ return line;
105
+ });
106
+
107
+ if (updated) {
108
+ fs.writeFileSync(filePath, newLines.join("\n"));
109
+ // If the stream points to this file, re-open it
110
+ if (date === this.currentDate) {
111
+ this.currentStream?.end();
112
+ this.currentStream = fs.createWriteStream(filePath, { flags: "a" });
113
+ }
114
+ }
115
+ return updated;
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+
121
+ /** Read all records for a given date. */
122
+ queryDay(date: string): FeedbackRecord[] {
123
+ const filePath = path.join(this.dir, `${date}.jsonl`);
124
+ try {
125
+ const content = fs.readFileSync(filePath, "utf-8");
126
+ return content.split("\n")
127
+ .filter(line => line.trim())
128
+ .map(line => JSON.parse(line) as FeedbackRecord);
129
+ } catch {
130
+ return [];
131
+ }
132
+ }
133
+
134
+ /** Read recent records across today and yesterday. */
135
+ queryRecent(limit = 20): FeedbackRecord[] {
136
+ const results: FeedbackRecord[] = [];
137
+ const today = new Date();
138
+
139
+ // Check today and up to 6 previous days to fill the limit
140
+ for (let d = 0; d < 7 && results.length < limit; d++) {
141
+ const date = new Date(today);
142
+ date.setDate(date.getDate() - d);
143
+ const dateStr = date.toISOString().slice(0, 10);
144
+ const dayRecords = this.queryDay(dateStr);
145
+ results.push(...dayRecords);
146
+ }
147
+
148
+ // Sort newest first, truncate
149
+ return results.sort((a, b) => b.ts - a.ts).slice(0, limit);
150
+ }
151
+
152
+ /** Aggregate stats across recent records. */
153
+ getStats(): Record<string, unknown> {
154
+ const records = this.queryRecent(100);
155
+ if (records.length === 0) {
156
+ return { totalRecords: 0, withSignals: 0, avgCompositeScore: null };
157
+ }
158
+
159
+ const withSignals = records.filter(r => r.signals.compositeScore !== 0 || r.signals.errorCleared !== null);
160
+ const scores = withSignals.map(r => r.signals.compositeScore).filter(s => s !== 0);
161
+ const avgScore = scores.length > 0
162
+ ? scores.reduce((a, b) => a + b, 0) / scores.length
163
+ : null;
164
+
165
+ // Tag distribution
166
+ const tagCounts: Record<string, number> = {};
167
+ for (const r of records) {
168
+ for (const t of r.tags) {
169
+ tagCounts[t] = (tagCounts[t] || 0) + 1;
170
+ }
171
+ }
172
+
173
+ return {
174
+ totalRecords: records.length,
175
+ withSignals: withSignals.length,
176
+ avgCompositeScore: avgScore !== null ? Math.round(avgScore * 1000) / 1000 : null,
177
+ avgLatencyMs: Math.round(records.reduce((s, r) => s + r.responseLatencyMs, 0) / records.length),
178
+ topTags: Object.entries(tagCounts).sort((a, b) => b[1] - a[1]).slice(0, 10),
179
+ };
180
+ }
181
+
182
+ /** Close the write stream. */
183
+ destroy(): void {
184
+ if (this.currentStream) {
185
+ this.currentStream.end();
186
+ this.currentStream = null;
187
+ }
188
+ }
189
+
190
+ /** Prune files older than retentionDays. */
191
+ prune(): number {
192
+ const cutoff = Date.now() - this.retentionDays * 86_400_000;
193
+ let pruned = 0;
194
+ try {
195
+ for (const file of fs.readdirSync(this.dir)) {
196
+ if (!file.endsWith(".jsonl")) continue;
197
+ const dateStr = file.replace(".jsonl", "");
198
+ const fileDate = new Date(dateStr).getTime();
199
+ if (fileDate && fileDate < cutoff) {
200
+ fs.unlinkSync(path.join(this.dir, file));
201
+ pruned++;
202
+ }
203
+ }
204
+ if (pruned > 0) log(TAG, `pruned ${pruned} old feedback files`);
205
+ } catch (err: any) {
206
+ error(TAG, "prune failed:", err.message);
207
+ }
208
+ return pruned;
209
+ }
210
+
211
+ // ── Private ──
212
+
213
+ private rotateIfNeeded(): void {
214
+ const date = new Date().toISOString().slice(0, 10);
215
+ if (date !== this.currentDate) {
216
+ if (this.currentStream) {
217
+ this.currentStream.end();
218
+ }
219
+ const filePath = path.join(this.dir, `${date}.jsonl`);
220
+ this.currentStream = fs.createWriteStream(filePath, { flags: "a" });
221
+ this.currentDate = date;
222
+ log(TAG, `writing to ${filePath}`);
223
+ }
224
+ }
225
+
226
+ private deriveTags(params: {
227
+ escalationReasons: string[];
228
+ currentApp: string;
229
+ codingContext: boolean;
230
+ }): string[] {
231
+ const tags: string[] = [];
232
+
233
+ // From escalation reasons
234
+ for (const r of params.escalationReasons) {
235
+ const category = r.split(":")[0];
236
+ if (category && !tags.includes(category)) {
237
+ tags.push(category);
238
+ }
239
+ }
240
+
241
+ // App category
242
+ if (params.currentApp) {
243
+ tags.push(`app:${params.currentApp.toLowerCase().slice(0, 30)}`);
244
+ }
245
+
246
+ // Coding context
247
+ if (params.codingContext) {
248
+ tags.push("coding");
249
+ }
250
+
251
+ return tags;
252
+ }
253
+ }
@@ -0,0 +1,218 @@
1
+ import type { FeedbackSignals, FeedbackRecord } from "../types.js";
2
+ import type { FeedbackStore } from "./feedback-store.js";
3
+ import type { AgentLoop } from "../agent/loop.js";
4
+ import type { SenseBuffer } from "../buffers/sense-buffer.js";
5
+ import { log, warn } from "../log.js";
6
+
7
+ const TAG = "signal-collector";
8
+
9
+ /** Error patterns matching scorer.ts */
10
+ const ERROR_PATTERNS = [
11
+ "error", "failed", "failure", "exception", "crash", "traceback",
12
+ "typeerror", "referenceerror", "syntaxerror", "cannot read", "undefined is not",
13
+ "exit code", "segfault", "panic", "fatal", "enoent",
14
+ ];
15
+
16
+ function hasErrorPattern(text: string): boolean {
17
+ const lower = text.toLowerCase();
18
+ return ERROR_PATTERNS.some(p => lower.includes(p));
19
+ }
20
+
21
+ interface PendingCollection {
22
+ recordId: string;
23
+ recordTs: number;
24
+ recordDate: string; // YYYY-MM-DD for file lookup
25
+ escalationReasons: string[];
26
+ digestAtEscalation: string;
27
+ timers: ReturnType<typeof setTimeout>[];
28
+ }
29
+
30
+ /**
31
+ * Deferred signal backfill for feedback records.
32
+ *
33
+ * After each escalation, schedules checks at 60s, 120s, and 300s
34
+ * to read from existing buffers and compute feedback signals.
35
+ * At 300s (the final check), writes the composite score and persists.
36
+ */
37
+ export class SignalCollector {
38
+ private pending = new Map<string, PendingCollection>();
39
+
40
+ constructor(
41
+ private feedbackStore: FeedbackStore,
42
+ private agentLoop: AgentLoop,
43
+ private senseBuffer: SenseBuffer,
44
+ ) {}
45
+
46
+ /** Schedule signal collection for a feedback record. */
47
+ schedule(record: FeedbackRecord): void {
48
+ const date = new Date(record.ts).toISOString().slice(0, 10);
49
+ const entry: PendingCollection = {
50
+ recordId: record.id,
51
+ recordTs: record.ts,
52
+ recordDate: date,
53
+ escalationReasons: record.escalationReasons,
54
+ digestAtEscalation: record.digest,
55
+ timers: [],
56
+ };
57
+
58
+ // Schedule partial collections at 60s and 120s, final at 300s
59
+ entry.timers.push(setTimeout(() => this.collect(entry, "partial"), 60_000));
60
+ entry.timers.push(setTimeout(() => this.collect(entry, "partial"), 120_000));
61
+ entry.timers.push(setTimeout(() => this.collect(entry, "final"), 300_000));
62
+
63
+ this.pending.set(record.id, entry);
64
+ log(TAG, `scheduled signal collection for record ${record.id} (tick #${record.tickId})`);
65
+ }
66
+
67
+ /** Cancel all pending collections. Called on shutdown. */
68
+ destroy(): void {
69
+ for (const entry of this.pending.values()) {
70
+ for (const t of entry.timers) clearTimeout(t);
71
+ }
72
+ this.pending.clear();
73
+ }
74
+
75
+ get pendingCount(): number {
76
+ return this.pending.size;
77
+ }
78
+
79
+ // ── Private ──
80
+
81
+ private collect(entry: PendingCollection, phase: "partial" | "final"): void {
82
+ try {
83
+ const signals = this.computeSignals(entry);
84
+
85
+ const updated = this.feedbackStore.updateSignals(
86
+ entry.recordId,
87
+ entry.recordDate,
88
+ signals,
89
+ );
90
+
91
+ if (phase === "final") {
92
+ this.pending.delete(entry.recordId);
93
+ log(TAG, `final signals for ${entry.recordId}: score=${signals.compositeScore.toFixed(2)}, err=${signals.errorCleared}, reesc=${signals.noReEscalation}`);
94
+ }
95
+
96
+ if (!updated && phase === "final") {
97
+ warn(TAG, `could not update signals for ${entry.recordId} — record not found in ${entry.recordDate}.jsonl`);
98
+ }
99
+ } catch (err: any) {
100
+ warn(TAG, `signal collection error for ${entry.recordId}: ${err.message}`);
101
+ if (phase === "final") {
102
+ this.pending.delete(entry.recordId);
103
+ }
104
+ }
105
+ }
106
+
107
+ private computeSignals(entry: PendingCollection): FeedbackSignals {
108
+ const now = Date.now();
109
+ const elapsedMs = now - entry.recordTs;
110
+
111
+ // ── errorCleared: check if error patterns are absent in recent digests ──
112
+ let errorCleared: boolean | null = null;
113
+ const hadError = entry.escalationReasons.some(r => r.startsWith("error:"));
114
+ if (hadError) {
115
+ // Look at the 3 most recent agent entries
116
+ const recentEntries = this.agentLoop.getHistory(3);
117
+ if (recentEntries.length > 0) {
118
+ // All recent entries should be free of error patterns
119
+ errorCleared = recentEntries.every(e => !hasErrorPattern(e.digest));
120
+ }
121
+ }
122
+
123
+ // ── noReEscalation: same reasons haven't fired within 5 min ──
124
+ // We check by looking at recent feedback records for overlapping reasons
125
+ let noReEscalation: boolean | null = null;
126
+ if (elapsedMs >= 60_000) {
127
+ const recentRecords = this.feedbackStore.queryRecent(10);
128
+ const reEscalated = recentRecords.some(r =>
129
+ r.id !== entry.recordId &&
130
+ r.ts > entry.recordTs &&
131
+ r.ts <= entry.recordTs + 300_000 &&
132
+ r.escalationReasons.some(reason => entry.escalationReasons.includes(reason))
133
+ );
134
+ noReEscalation = !reEscalated;
135
+ }
136
+
137
+ // ── dwellTimeMs: time from escalation until the next HUD push ──
138
+ let dwellTimeMs: number | null = null;
139
+ const historyEntries = this.agentLoop.getHistory(20);
140
+ for (const e of historyEntries) {
141
+ if (e.ts > entry.recordTs && e.pushed) {
142
+ dwellTimeMs = e.ts - entry.recordTs;
143
+ break;
144
+ }
145
+ }
146
+
147
+ // ── quickAppSwitch: app changed within 10s of escalation ──
148
+ let quickAppSwitch: boolean | null = null;
149
+ const appHistory = this.senseBuffer.appHistory(entry.recordTs);
150
+ if (appHistory.length >= 2) {
151
+ // Check if there was an app switch within 10s of escalation
152
+ const earlySwitch = appHistory.find(a =>
153
+ a.ts > entry.recordTs && a.ts <= entry.recordTs + 10_000
154
+ );
155
+ quickAppSwitch = earlySwitch !== undefined;
156
+ }
157
+
158
+ // ── compositeScore: weighted combination ──
159
+ const compositeScore = this.computeComposite({
160
+ errorCleared,
161
+ noReEscalation,
162
+ dwellTimeMs,
163
+ quickAppSwitch,
164
+ });
165
+
166
+ return {
167
+ errorCleared,
168
+ noReEscalation,
169
+ dwellTimeMs,
170
+ quickAppSwitch,
171
+ compositeScore,
172
+ };
173
+ }
174
+
175
+ private computeComposite(signals: {
176
+ errorCleared: boolean | null;
177
+ noReEscalation: boolean | null;
178
+ dwellTimeMs: number | null;
179
+ quickAppSwitch: boolean | null;
180
+ }): number {
181
+ let score = 0;
182
+ let weight = 0;
183
+
184
+ // Error cleared: strong positive (+0.5)
185
+ if (signals.errorCleared !== null) {
186
+ score += signals.errorCleared ? 0.5 : -0.3;
187
+ weight += 0.5;
188
+ }
189
+
190
+ // No re-escalation: positive (+0.3)
191
+ if (signals.noReEscalation !== null) {
192
+ score += signals.noReEscalation ? 0.3 : -0.2;
193
+ weight += 0.3;
194
+ }
195
+
196
+ // Dwell time: weak positive if > 60s
197
+ if (signals.dwellTimeMs !== null) {
198
+ if (signals.dwellTimeMs > 60_000) {
199
+ score += 0.15;
200
+ } else if (signals.dwellTimeMs < 10_000) {
201
+ score -= 0.1;
202
+ }
203
+ weight += 0.15;
204
+ }
205
+
206
+ // Quick app switch: weak negative
207
+ if (signals.quickAppSwitch !== null) {
208
+ score += signals.quickAppSwitch ? -0.15 : 0.05;
209
+ weight += 0.1;
210
+ }
211
+
212
+ // Normalize if we have signals, otherwise return 0
213
+ if (weight === 0) return 0;
214
+
215
+ // Clamp to [-1, 1]
216
+ return Math.max(-1, Math.min(1, score));
217
+ }
218
+ }
@@ -0,0 +1,24 @@
1
+ /** Timestamped structured logger — writes to stderr for easy piping */
2
+
3
+ const DEBUG = process.env.DEBUG === "true" || process.env.LOG_LEVEL === "debug";
4
+
5
+ function ts(): string {
6
+ return new Date().toISOString();
7
+ }
8
+
9
+ export function debug(tag: string, ...args: unknown[]): void {
10
+ if (!DEBUG) return;
11
+ console.log(`[${ts()}] [${tag}] 🐛`, ...args);
12
+ }
13
+
14
+ export function log(tag: string, ...args: unknown[]): void {
15
+ console.log(`[${ts()}] [${tag}]`, ...args);
16
+ }
17
+
18
+ export function warn(tag: string, ...args: unknown[]): void {
19
+ console.warn(`[${ts()}] [${tag}] \u26a0`, ...args);
20
+ }
21
+
22
+ export function error(tag: string, ...args: unknown[]): void {
23
+ console.error(`[${ts()}] [${tag}] \u2718`, ...args);
24
+ }
@@ -0,0 +1,126 @@
1
+ import type { InboundMessage } from "../types.js";
2
+ import type { WsHandler } from "./ws-handler.js";
3
+ import type { AudioPipeline } from "../audio/pipeline.js";
4
+ import type { CoreConfig } from "../types.js";
5
+ import { WebSocket } from "ws";
6
+ import { log } from "../log.js";
7
+
8
+ const TAG = "cmd";
9
+
10
+ export interface CommandDeps {
11
+ wsHandler: WsHandler;
12
+ systemAudioPipeline: AudioPipeline;
13
+ micPipeline: AudioPipeline | null;
14
+ config: CoreConfig;
15
+ onUserMessage: (text: string) => Promise<void>;
16
+ /** Toggle screen capture — returns new state */
17
+ onToggleScreen: () => boolean;
18
+ /** Toggle trait voices — returns new enabled state */
19
+ onToggleTraits?: () => boolean;
20
+ }
21
+
22
+ /**
23
+ * Handle overlay commands and user messages.
24
+ * Registers as the WS handler's onIncoming callback.
25
+ */
26
+ export function setupCommands(deps: CommandDeps): void {
27
+ const { wsHandler } = deps;
28
+
29
+ wsHandler.onIncoming(async (msg: InboundMessage, _client: WebSocket) => {
30
+ switch (msg.type) {
31
+ case "message": {
32
+ log(TAG, `routing user message to OpenClaw`);
33
+ try {
34
+ await deps.onUserMessage(msg.text);
35
+ } catch {
36
+ wsHandler.broadcast("\u26a0 Failed to reach Sinain. Check gateway connection.", "high");
37
+ }
38
+ break;
39
+ }
40
+ case "command": {
41
+ handleCommand(msg.action, deps);
42
+ log(TAG, `command processed: ${msg.action}`);
43
+ break;
44
+ }
45
+ }
46
+ });
47
+ }
48
+
49
+ function handleCommand(action: string, deps: CommandDeps): void {
50
+ const { wsHandler, systemAudioPipeline, micPipeline, config } = deps;
51
+
52
+ switch (action) {
53
+ case "toggle_audio": {
54
+ const isSck = systemAudioPipeline.getCaptureCommand() === "screencapturekit";
55
+ if (systemAudioPipeline.isRunning() && !systemAudioPipeline.isMuted()) {
56
+ if (isSck) {
57
+ // sck-capture also captures screen — keep process alive, just mute audio
58
+ systemAudioPipeline.mute();
59
+ log(TAG, "system audio muted (sck-capture still running for screen)");
60
+ } else {
61
+ // sox/ffmpeg are audio-only — full stop
62
+ systemAudioPipeline.stop();
63
+ log(TAG, "system audio stopped");
64
+ }
65
+ wsHandler.broadcast("System audio muted", "normal");
66
+ } else if (systemAudioPipeline.isRunning() && systemAudioPipeline.isMuted()) {
67
+ systemAudioPipeline.unmute();
68
+ wsHandler.broadcast("System audio unmuted", "normal");
69
+ log(TAG, "system audio unmuted");
70
+ } else {
71
+ systemAudioPipeline.start();
72
+ wsHandler.broadcast("System audio capture started", "normal");
73
+ log(TAG, "system audio started (was not running)");
74
+ }
75
+ break;
76
+ }
77
+ case "toggle_mic": {
78
+ if (!micPipeline) {
79
+ wsHandler.broadcast("\u26a0 Mic not enabled (set MIC_ENABLED=true)", "normal");
80
+ log(TAG, "toggle_mic: mic not enabled");
81
+ break;
82
+ }
83
+ if (micPipeline.isRunning()) {
84
+ micPipeline.stop();
85
+ wsHandler.broadcast("Mic capture stopped", "normal");
86
+ log(TAG, "mic toggled OFF");
87
+ } else {
88
+ micPipeline.start();
89
+ wsHandler.broadcast("Mic capture started", "normal");
90
+ log(TAG, "mic toggled ON");
91
+ }
92
+ break;
93
+ }
94
+ case "toggle_screen": {
95
+ const nowActive = deps.onToggleScreen();
96
+ wsHandler.broadcast(
97
+ nowActive ? "Screen capture started" : "Screen capture stopped",
98
+ "normal"
99
+ );
100
+ log(TAG, `screen toggled ${nowActive ? "ON" : "OFF"}`);
101
+ break;
102
+ }
103
+ case "switch_device": {
104
+ const current = systemAudioPipeline.getDevice();
105
+ const alt = config.audioAltDevice;
106
+ const next = current === config.audioConfig.device ? alt : config.audioConfig.device;
107
+ systemAudioPipeline.switchDevice(next);
108
+ wsHandler.broadcast(`Audio device \u2192 ${next}`, "normal");
109
+ log(TAG, `audio device switched: ${current} \u2192 ${next}`);
110
+ break;
111
+ }
112
+ case "toggle_traits": {
113
+ if (!deps.onToggleTraits) {
114
+ wsHandler.broadcast("Trait voices not configured", "normal");
115
+ break;
116
+ }
117
+ const nowEnabled = deps.onToggleTraits();
118
+ wsHandler.updateState({ traits: nowEnabled ? "active" : "off" });
119
+ wsHandler.broadcast(`Trait voices ${nowEnabled ? "on" : "off"}`, "normal");
120
+ log(TAG, `traits toggled ${nowEnabled ? "ON" : "OFF"}`);
121
+ break;
122
+ }
123
+ default:
124
+ log(TAG, `unhandled command: ${action}`);
125
+ }
126
+ }