@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,278 @@
1
+ import { EventEmitter } from "node:events";
2
+ import type { TranscriptionConfig, AudioChunk, TranscriptResult } from "../types.js";
3
+ import type { Profiler } from "../profiler.js";
4
+ import { LocalTranscriptionBackend } from "./transcription-local.js";
5
+ import { log, warn, error, debug } from "../log.js";
6
+
7
+ const TAG = "transcribe";
8
+
9
+ /** Detect repeated-token hallucinations like "kuch kuch kuch kuch..." */
10
+ function isHallucination(text: string): boolean {
11
+ const words = text.split(/[\s,]+/).filter(Boolean);
12
+ if (words.length < 6) return false;
13
+ const freq = new Map<string, number>();
14
+ for (const w of words) {
15
+ const lw = w.toLowerCase();
16
+ freq.set(lw, (freq.get(lw) || 0) + 1);
17
+ }
18
+ const maxFreq = Math.max(...freq.values());
19
+ return maxFreq / words.length > 0.6;
20
+ }
21
+
22
+ /**
23
+ * Transcription service — sends audio chunks to OpenRouter (Gemini) for transcription.
24
+ *
25
+ * Events: 'transcript' (TranscriptResult)
26
+ */
27
+ export class TranscriptionService extends EventEmitter {
28
+ private config: TranscriptionConfig;
29
+ private destroyed: boolean = false;
30
+ private pendingRequests: number = 0;
31
+ private readonly MAX_CONCURRENT = 5;
32
+ private localBackend: LocalTranscriptionBackend | null = null;
33
+
34
+ private latencies: number[] = [];
35
+ private cumulativeLatencies: number[] = [];
36
+ private latencyStatsTimer: ReturnType<typeof setInterval> | null = null;
37
+ private totalAudioDurationMs: number = 0;
38
+ private totalTokensConsumed: number = 0;
39
+ private profiler: Profiler | null = null;
40
+ private errorCount: number = 0;
41
+ private dropCount: number = 0;
42
+ private totalCalls: number = 0;
43
+
44
+ setProfiler(p: Profiler): void { this.profiler = p; }
45
+
46
+ constructor(config: TranscriptionConfig) {
47
+ super();
48
+ this.config = config;
49
+
50
+ if (config.backend === "local") {
51
+ this.localBackend = new LocalTranscriptionBackend(config.local);
52
+ } else if (!config.openrouterApiKey) {
53
+ warn(TAG, "OpenRouter API key not set \u2014 transcription will fail");
54
+ }
55
+
56
+ log(TAG, `initialized: backend=${config.backend} model=${config.geminiModel} language=${config.language}`);
57
+
58
+ this.latencyStatsTimer = setInterval(() => this.logStats(), 60_000);
59
+ }
60
+
61
+ async processChunk(chunk: AudioChunk): Promise<void> {
62
+ if (this.destroyed) return;
63
+ this.totalCalls++;
64
+
65
+ if (this.pendingRequests >= this.MAX_CONCURRENT) {
66
+ this.dropCount++;
67
+ this.profiler?.gauge("transcription.drops", this.dropCount);
68
+ warn(TAG, `dropping chunk: ${this.pendingRequests} requests already pending`);
69
+ return;
70
+ }
71
+
72
+ this.pendingRequests++;
73
+ this.profiler?.gauge("transcription.pending", this.pendingRequests);
74
+ try {
75
+ if (this.localBackend) {
76
+ await this.transcribeViaLocal(chunk);
77
+ } else {
78
+ await this.transcribeViaOpenRouter(chunk);
79
+ }
80
+ } catch (err) {
81
+ this.errorCount++;
82
+ this.profiler?.gauge("transcription.errors", this.errorCount);
83
+ error(TAG, "transcription failed:", err instanceof Error ? err.message : err);
84
+ } finally {
85
+ this.pendingRequests--;
86
+ this.profiler?.gauge("transcription.pending", this.pendingRequests);
87
+ }
88
+ }
89
+
90
+ destroy(): void {
91
+ this.destroyed = true;
92
+ this.localBackend?.destroy();
93
+ if (this.latencyStatsTimer) { clearInterval(this.latencyStatsTimer); this.latencyStatsTimer = null; }
94
+ this.logStats();
95
+ this.removeAllListeners();
96
+ log(TAG, "destroyed");
97
+ }
98
+
99
+ private logStats(): void {
100
+ if (this.latencies.length === 0) return;
101
+
102
+ const sorted = [...this.latencies].sort((a, b) => a - b);
103
+ const p50 = sorted[Math.floor(sorted.length / 2)];
104
+ const p95 = sorted[Math.floor(sorted.length * 0.95)];
105
+ const avg = sorted.reduce((a, b) => a + b, 0) / sorted.length;
106
+
107
+ log(TAG, `latency stats (n=${sorted.length}): p50=${Math.round(p50)}ms p95=${Math.round(p95)}ms avg=${Math.round(avg)}ms`);
108
+
109
+ if (this.totalAudioDurationMs > 0) {
110
+ const audioMinutes = this.totalAudioDurationMs / 60_000;
111
+ const costPerMToken = 0.075;
112
+ const estimatedCost = (this.totalTokensConsumed / 1_000_000) * costPerMToken;
113
+ const costPerMinute = audioMinutes > 0 ? estimatedCost / audioMinutes : 0;
114
+ log(TAG, `cost stats: ${this.totalTokensConsumed} tokens, ${audioMinutes.toFixed(1)} audio-min, ~$${estimatedCost.toFixed(6)} total, ~$${costPerMinute.toFixed(6)}/audio-min`);
115
+ }
116
+
117
+ this.latencies = [];
118
+ }
119
+
120
+ // ── Local whisper backend ──
121
+
122
+ private async transcribeViaLocal(chunk: AudioChunk): Promise<void> {
123
+ const startTs = Date.now();
124
+ const result = await this.localBackend!.transcribe(chunk);
125
+ const elapsed = Date.now() - startTs;
126
+
127
+ this.latencies.push(elapsed);
128
+ this.cumulativeLatencies.push(elapsed);
129
+ if (this.cumulativeLatencies.length > 1_000) this.cumulativeLatencies.shift();
130
+ this.profiler?.timerRecord("transcription.call", elapsed);
131
+ this.totalAudioDurationMs += chunk.durationMs;
132
+
133
+ if (!result) return;
134
+
135
+ const { text } = result;
136
+
137
+ if (text.length < 3) {
138
+ debug(TAG, `transcript too short, dropping: "${text}"`);
139
+ return;
140
+ }
141
+
142
+ if (isHallucination(text)) {
143
+ warn(TAG, `hallucination detected, dropping: "${text.slice(0, 80)}..."`);
144
+ return;
145
+ }
146
+
147
+ this.emit("transcript", result);
148
+ }
149
+
150
+ // ── OpenRouter backend ──
151
+
152
+ /** Get cumulative profiling stats for /health. */
153
+ getProfilingStats(): Record<string, unknown> {
154
+ const sorted = [...this.cumulativeLatencies].sort((a, b) => a - b);
155
+ const n = sorted.length;
156
+ const p50 = n > 0 ? sorted[Math.floor(n / 2)] : 0;
157
+ const p95 = n > 0 ? sorted[Math.floor(n * 0.95)] : 0;
158
+ const avg = n > 0 ? sorted.reduce((a, b) => a + b, 0) / n : 0;
159
+ const audioMinutes = this.totalAudioDurationMs / 60_000;
160
+ const costPerMToken = 0.075;
161
+ const estimatedCost = (this.totalTokensConsumed / 1_000_000) * costPerMToken;
162
+
163
+ return {
164
+ backend: this.config.backend,
165
+ calls: this.totalCalls,
166
+ p50Ms: Math.round(p50),
167
+ p95Ms: Math.round(p95),
168
+ avgMs: Math.round(avg),
169
+ totalAudioMinutes: Math.round(audioMinutes * 10) / 10,
170
+ estimatedCost: Math.round(estimatedCost * 1_000_000) / 1_000_000,
171
+ errors: this.errorCount,
172
+ drops: this.dropCount,
173
+ };
174
+ }
175
+
176
+ private async transcribeViaOpenRouter(chunk: AudioChunk): Promise<void> {
177
+ if (!this.config.openrouterApiKey) {
178
+ this.errorCount++;
179
+ this.profiler?.gauge("transcription.errors", this.errorCount);
180
+ error(TAG, "OpenRouter API key not configured");
181
+ return;
182
+ }
183
+
184
+ const base64Audio = chunk.buffer.toString("base64");
185
+ const startTs = Date.now();
186
+
187
+ debug(TAG, `sending ${chunk.durationMs}ms chunk to OpenRouter (${Math.round(chunk.buffer.length / 1024)}KB)`);
188
+
189
+ const controller = new AbortController();
190
+ const timeout = setTimeout(() => controller.abort(), 30_000);
191
+
192
+ try {
193
+ const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
194
+ method: "POST",
195
+ headers: {
196
+ "Authorization": `Bearer ${this.config.openrouterApiKey}`,
197
+ "Content-Type": "application/json",
198
+ },
199
+ body: JSON.stringify({
200
+ model: this.config.geminiModel,
201
+ messages: [{
202
+ role: "user",
203
+ content: [
204
+ { type: "input_audio", input_audio: { data: base64Audio, format: "wav" } },
205
+ { type: "text", text: `Transcribe this audio in ${this.config.language}. Output ONLY the transcript text, nothing else. If the audio is not in ${this.config.language}, output an empty string.` },
206
+ ],
207
+ }],
208
+ }),
209
+ signal: controller.signal,
210
+ });
211
+
212
+ if (!response.ok) {
213
+ this.errorCount++;
214
+ this.profiler?.gauge("transcription.errors", this.errorCount);
215
+ const body = await response.text().catch(() => "(no body)");
216
+ error(TAG, `OpenRouter error ${response.status}: ${body.slice(0, 300)}`);
217
+ return;
218
+ }
219
+
220
+ const data = await response.json() as {
221
+ choices?: Array<{ message?: { content?: string } }>;
222
+ usage?: { prompt_tokens?: number; completion_tokens?: number };
223
+ };
224
+
225
+ const text = data.choices?.[0]?.message?.content?.trim();
226
+ const elapsed = Date.now() - startTs;
227
+
228
+ this.latencies.push(elapsed);
229
+ this.cumulativeLatencies.push(elapsed);
230
+ if (this.cumulativeLatencies.length > 1_000) this.cumulativeLatencies.shift();
231
+ this.profiler?.timerRecord("transcription.call", elapsed);
232
+ this.totalAudioDurationMs += chunk.durationMs;
233
+
234
+ if (!text) {
235
+ warn(TAG, `OpenRouter returned empty transcript (${elapsed}ms)`);
236
+ return;
237
+ }
238
+
239
+ if (text.length < 3) {
240
+ debug(TAG, `transcript too short, dropping: "${text}"`);
241
+ return;
242
+ }
243
+
244
+ if (isHallucination(text)) {
245
+ warn(TAG, `hallucination detected, dropping: "${text.slice(0, 80)}..."`);
246
+ return;
247
+ }
248
+
249
+ log(TAG, `transcript (${elapsed}ms): "${text.slice(0, 100)}${text.length > 100 ? "..." : ""}"`);
250
+
251
+ if (data.usage) {
252
+ this.totalTokensConsumed += (data.usage.prompt_tokens || 0) + (data.usage.completion_tokens || 0);
253
+ }
254
+
255
+ const result: TranscriptResult = {
256
+ text,
257
+ source: "openrouter",
258
+ refined: false,
259
+ confidence: 0.8,
260
+ ts: Date.now(),
261
+ audioSource: chunk.audioSource,
262
+ };
263
+
264
+ this.emit("transcript", result);
265
+ } catch (err) {
266
+ if (err instanceof Error && err.name === "AbortError") {
267
+ this.errorCount++;
268
+ this.profiler?.gauge("transcription.errors", this.errorCount);
269
+ warn(TAG, "OpenRouter request timed out (30s)");
270
+ } else {
271
+ throw err;
272
+ }
273
+ } finally {
274
+ clearTimeout(timeout);
275
+ }
276
+ }
277
+
278
+ }
@@ -0,0 +1,71 @@
1
+ import type { FeedItem, Priority, FeedChannel } from "../types.js";
2
+
3
+ /**
4
+ * Ring buffer for all feed items (audio transcripts, agent HUD, OpenClaw responses, system).
5
+ * Single source of truth — replaces both relay's messages[] and bridge's OpenClawClient polling.
6
+ */
7
+ export class FeedBuffer {
8
+ private items: FeedItem[] = [];
9
+ private nextId = 1;
10
+ private _version = 0;
11
+ private maxSize: number;
12
+ private _hwm = 0;
13
+
14
+ constructor(maxSize = 100) {
15
+ this.maxSize = maxSize;
16
+ }
17
+
18
+ /** Push a new feed item. Returns the created item. */
19
+ push(text: string, priority: Priority, source: FeedItem["source"], channel: FeedChannel = "stream"): FeedItem {
20
+ const item: FeedItem = {
21
+ id: this.nextId++,
22
+ text,
23
+ priority,
24
+ ts: Date.now(),
25
+ source,
26
+ channel,
27
+ };
28
+ this.items.push(item);
29
+ if (this.items.length > this._hwm) this._hwm = this.items.length;
30
+ if (this.items.length > this.maxSize) {
31
+ this.items.shift();
32
+ }
33
+ this._version++;
34
+ return item;
35
+ }
36
+
37
+ /** High-water mark: max number of items ever held simultaneously. */
38
+ get hwm(): number {
39
+ return this._hwm;
40
+ }
41
+
42
+ /** Query items with id > after. Excludes [PERIODIC] items from overlay responses. */
43
+ query(after = 0): FeedItem[] {
44
+ return this.items.filter(m => m.id > after && !m.text.startsWith("[PERIODIC]"));
45
+ }
46
+
47
+ /** Query items by source within a time window. */
48
+ queryBySource(source: string, since = 0): FeedItem[] {
49
+ return this.items.filter(m => m.source === source && m.ts >= since);
50
+ }
51
+
52
+ /** Query all items within a time window. */
53
+ queryByTime(since: number): FeedItem[] {
54
+ return this.items.filter(m => m.ts >= since);
55
+ }
56
+
57
+ /** Get the latest feed item, or null if empty. */
58
+ latest(): FeedItem | null {
59
+ return this.items.length > 0 ? this.items[this.items.length - 1] : null;
60
+ }
61
+
62
+ /** Current number of items. */
63
+ get size(): number {
64
+ return this.items.length;
65
+ }
66
+
67
+ /** Monotonically increasing version — bumps on every push. */
68
+ get version(): number {
69
+ return this._version;
70
+ }
71
+ }