@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,425 @@
1
+ import type { SenseEvent } from "../types.js";
2
+ import { levelFor } from "../privacy/index.js";
3
+
4
+ /**
5
+ * Delta change from semantic layer
6
+ */
7
+ export interface TextDelta {
8
+ type: "add" | "remove" | "modify" | "initial";
9
+ location: string;
10
+ delta: string;
11
+ context?: string;
12
+ }
13
+
14
+ /**
15
+ * Semantic context from new sense_client
16
+ */
17
+ export interface SemanticContext {
18
+ app: string;
19
+ window: string;
20
+ activity: string;
21
+ duration_s: number;
22
+ }
23
+
24
+ /**
25
+ * Extended sense event with semantic data
26
+ */
27
+ export interface SemanticSenseEvent extends SenseEvent {
28
+ // Semantic layer additions
29
+ semantic?: {
30
+ context: SemanticContext;
31
+ changes: TextDelta[];
32
+ visible?: {
33
+ summary?: string;
34
+ has_error?: boolean;
35
+ has_unsaved?: boolean;
36
+ };
37
+ };
38
+ // Priority from WebSocket sender
39
+ priority?: "urgent" | "high" | "normal";
40
+ }
41
+
42
+ /**
43
+ * Ring buffer for screen capture events from sense_client.
44
+ * Stores OCR text, app context, SSIM scores, and recent images.
45
+ * Single source of truth — replaces relay's senseBuffer + bridge's SensePoller.
46
+ *
47
+ * Image memory management: only the N most recent events retain imageData.
48
+ * Older events have their imageData stripped to prevent unbounded memory growth.
49
+ *
50
+ * Smart deduplication: events with very high SSIM AND similar OCR are deduplicated
51
+ * (configurable via SENSE_SSIM_DEDUP_THRESHOLD and SENSE_OCR_DEDUP_THRESHOLD).
52
+ *
53
+ * Delta support: accumulates text deltas for efficient context queries.
54
+ */
55
+ export class SenseBuffer {
56
+ private events: SemanticSenseEvent[] = [];
57
+ private nextId = 1;
58
+ private _version = 0;
59
+ private maxSize: number;
60
+ private maxImagesKept: number;
61
+ private _hwm = 0;
62
+
63
+ // Deduplication stats
64
+ private _dedupCount = 0;
65
+
66
+ // Configurable thresholds (conservative defaults)
67
+ private ssimDedupThreshold: number;
68
+ private ocrDedupThreshold: number;
69
+
70
+ // Delta accumulation for efficient queries
71
+ private _accumulatedDeltas: TextDelta[] = [];
72
+ private _lastDeltaFlush = Date.now();
73
+
74
+ // Activity tracking
75
+ private _activityCounts: Map<string, number> = new Map();
76
+
77
+ constructor(maxSize = 60, maxImagesKept = 5) {
78
+ this.maxSize = maxSize;
79
+ this.maxImagesKept = maxImagesKept;
80
+ // Very conservative: only dedup when BOTH visual AND text are nearly identical
81
+ this.ssimDedupThreshold = parseFloat(process.env.SENSE_SSIM_DEDUP_THRESHOLD || "0.97");
82
+ this.ocrDedupThreshold = parseFloat(process.env.SENSE_OCR_DEDUP_THRESHOLD || "0.9");
83
+ }
84
+
85
+ /**
86
+ * Push a new sense event (auto-assigns id and receivedAt).
87
+ * Returns null if event was deduplicated (updates last event timestamp instead).
88
+ */
89
+ push(raw: Omit<SemanticSenseEvent, "id" | "receivedAt">): SemanticSenseEvent | null {
90
+ // Smart deduplication: skip if BOTH visual AND text are nearly identical to last event
91
+ if (this.events.length > 0) {
92
+ const last = this.events[this.events.length - 1];
93
+ const highSsim = raw.meta.ssim >= this.ssimDedupThreshold;
94
+ const sameOcr = this.isOcrSimilar(raw.ocr, last.ocr);
95
+
96
+ // Only dedup when BOTH conditions are met (conservative)
97
+ if (highSsim && sameOcr) {
98
+ // Update timestamp on existing event instead of creating new
99
+ last.receivedAt = Date.now();
100
+ this._dedupCount++;
101
+ this._version++; // Still bump version so version-based checks work
102
+ return null;
103
+ }
104
+ }
105
+
106
+ const event: SemanticSenseEvent = {
107
+ ...raw,
108
+ id: this.nextId++,
109
+ receivedAt: Date.now(),
110
+ };
111
+
112
+ // Privacy: strip imageData if screen_images local_buffer level is "none"
113
+ try {
114
+ const imgLevel = levelFor("screen_images", "local_buffer");
115
+ if (imgLevel === "none") {
116
+ delete event.imageData;
117
+ delete event.imageBbox;
118
+ }
119
+ } catch { /* privacy not yet initialized — keep image data */ }
120
+
121
+ // Track activity type
122
+ if (event.semantic?.context?.activity) {
123
+ const activity = event.semantic.context.activity;
124
+ this._activityCounts.set(activity, (this._activityCounts.get(activity) || 0) + 1);
125
+ }
126
+
127
+ // Accumulate deltas
128
+ if (event.semantic?.changes) {
129
+ for (const delta of event.semantic.changes) {
130
+ this._accumulatedDeltas.push(delta);
131
+ }
132
+ // Trim accumulated deltas (keep last 100)
133
+ if (this._accumulatedDeltas.length > 100) {
134
+ this._accumulatedDeltas = this._accumulatedDeltas.slice(-100);
135
+ }
136
+ }
137
+
138
+ this.events.push(event);
139
+ if (this.events.length > this._hwm) this._hwm = this.events.length;
140
+ if (this.events.length > this.maxSize) {
141
+ this.events.shift();
142
+ }
143
+ // Strip imageData from older events to manage memory
144
+ this.trimImages();
145
+ this._version++;
146
+ return event;
147
+ }
148
+
149
+ /**
150
+ * Push a delta-only update (no full event, just changes).
151
+ * Used for efficient incremental updates.
152
+ */
153
+ pushDelta(data: {
154
+ app: string;
155
+ activity: string;
156
+ changes: TextDelta[];
157
+ priority?: "urgent" | "high" | "normal";
158
+ ts: number;
159
+ }): void {
160
+ // Accumulate deltas
161
+ for (const delta of data.changes) {
162
+ this._accumulatedDeltas.push(delta);
163
+ }
164
+
165
+ // Update activity counts
166
+ this._activityCounts.set(data.activity, (this._activityCounts.get(data.activity) || 0) + 1);
167
+
168
+ // Trim accumulated deltas
169
+ if (this._accumulatedDeltas.length > 100) {
170
+ this._accumulatedDeltas = this._accumulatedDeltas.slice(-100);
171
+ }
172
+
173
+ this._version++;
174
+ }
175
+
176
+ /** Check if two OCR strings are similar enough to deduplicate. */
177
+ private isOcrSimilar(ocr1: string | undefined, ocr2: string | undefined): boolean {
178
+ // Both empty = similar
179
+ if (!ocr1 && !ocr2) return true;
180
+ // One empty, one not = different
181
+ if (!ocr1 || !ocr2) return false;
182
+ // Exact match = similar
183
+ if (ocr1 === ocr2) return true;
184
+
185
+ // Simple character-based similarity (faster than Levenshtein for long strings)
186
+ const shorter = ocr1.length < ocr2.length ? ocr1 : ocr2;
187
+ const longer = ocr1.length < ocr2.length ? ocr2 : ocr1;
188
+
189
+ // If lengths differ significantly, they're different
190
+ if (shorter.length / longer.length < this.ocrDedupThreshold) return false;
191
+
192
+ // Check prefix similarity (fast heuristic - most OCR differences are at the end)
193
+ const checkLen = Math.min(200, shorter.length);
194
+ let matches = 0;
195
+ for (let i = 0; i < checkLen; i++) {
196
+ if (shorter[i] === longer[i]) matches++;
197
+ }
198
+ return matches / checkLen >= this.ocrDedupThreshold;
199
+ }
200
+
201
+ /** Get deduplication stats. */
202
+ get dedupCount(): number {
203
+ return this._dedupCount;
204
+ }
205
+
206
+ /** High-water mark: max number of events ever held simultaneously. */
207
+ get hwm(): number {
208
+ return this._hwm;
209
+ }
210
+
211
+ /** Query events with id > after. Optionally strip image data. */
212
+ query(after = 0, metaOnly = false): SemanticSenseEvent[] {
213
+ let results = this.events.filter(e => e.id > after);
214
+ if (metaOnly) {
215
+ results = results.map(e => {
216
+ const stripped = { ...e } as any;
217
+ delete stripped.imageData;
218
+ if (stripped.roi) {
219
+ stripped.roi = { ...stripped.roi };
220
+ delete stripped.roi.data;
221
+ }
222
+ if (stripped.diff) {
223
+ stripped.diff = { ...stripped.diff };
224
+ delete stripped.diff.data;
225
+ }
226
+ return stripped;
227
+ });
228
+ }
229
+ return results;
230
+ }
231
+
232
+ /** Query events within a time window (by receivedAt). */
233
+ queryByTime(since: number): SemanticSenseEvent[] {
234
+ return this.events.filter(e => e.receivedAt >= since);
235
+ }
236
+
237
+ /**
238
+ * Query with semantic filters.
239
+ */
240
+ querySemantic(options: {
241
+ since?: number;
242
+ activity?: string;
243
+ hasError?: boolean;
244
+ limit?: number;
245
+ }): SemanticSenseEvent[] {
246
+ let results = this.events;
247
+
248
+ if (options.since) {
249
+ results = results.filter(e => e.receivedAt >= options.since!);
250
+ }
251
+
252
+ if (options.activity) {
253
+ results = results.filter(e =>
254
+ e.semantic?.context?.activity === options.activity
255
+ );
256
+ }
257
+
258
+ if (options.hasError !== undefined) {
259
+ results = results.filter(e =>
260
+ e.semantic?.visible?.has_error === options.hasError
261
+ );
262
+ }
263
+
264
+ if (options.limit) {
265
+ results = results.slice(-options.limit);
266
+ }
267
+
268
+ return results;
269
+ }
270
+
271
+ /**
272
+ * Get accumulated deltas since last flush.
273
+ */
274
+ getAccumulatedDeltas(flush = false): TextDelta[] {
275
+ const deltas = [...this._accumulatedDeltas];
276
+ if (flush) {
277
+ this._accumulatedDeltas = [];
278
+ this._lastDeltaFlush = Date.now();
279
+ }
280
+ return deltas;
281
+ }
282
+
283
+ /**
284
+ * Get activity breakdown for a time window.
285
+ */
286
+ getActivityBreakdown(since = 0): Record<string, number> {
287
+ if (since === 0) {
288
+ return Object.fromEntries(this._activityCounts);
289
+ }
290
+
291
+ const counts: Record<string, number> = {};
292
+ for (const e of this.events) {
293
+ if (e.receivedAt >= since && e.semantic?.context?.activity) {
294
+ const activity = e.semantic.context.activity;
295
+ counts[activity] = (counts[activity] || 0) + 1;
296
+ }
297
+ }
298
+ return counts;
299
+ }
300
+
301
+ /** Get recent events that have imageData, newest first. */
302
+ recentImages(count: number): SemanticSenseEvent[] {
303
+ const withImages: SemanticSenseEvent[] = [];
304
+ for (let i = this.events.length - 1; i >= 0 && withImages.length < count; i--) {
305
+ if (this.events[i].imageData) {
306
+ withImages.push(this.events[i]);
307
+ }
308
+ }
309
+ return withImages;
310
+ }
311
+
312
+ /** Get the most recent app name, or 'unknown'. */
313
+ latestApp(): string {
314
+ if (this.events.length === 0) return "unknown";
315
+ const last = this.events[this.events.length - 1];
316
+ return last.semantic?.context?.app || last.meta.app || "unknown";
317
+ }
318
+
319
+ /** Get current activity type. */
320
+ latestActivity(): string {
321
+ if (this.events.length === 0) return "unknown";
322
+ return this.events[this.events.length - 1].semantic?.context?.activity || "unknown";
323
+ }
324
+
325
+ /** Get distinct app transition timeline within a time window. */
326
+ appHistory(since = 0): { app: string; ts: number }[] {
327
+ const history: { app: string; ts: number }[] = [];
328
+ let lastApp = "";
329
+ for (const e of this.events) {
330
+ if (since > 0 && e.receivedAt < since) continue;
331
+ const app = e.semantic?.context?.app || e.meta.app || "unknown";
332
+ if (app !== lastApp) {
333
+ history.push({ app, ts: e.ts });
334
+ lastApp = app;
335
+ }
336
+ }
337
+ return history;
338
+ }
339
+
340
+ /** Get latest event. */
341
+ latest(): SemanticSenseEvent | null {
342
+ return this.events.length > 0 ? this.events[this.events.length - 1] : null;
343
+ }
344
+
345
+ /**
346
+ * Get structured context for agent consumption.
347
+ * This is the new semantic-aware endpoint.
348
+ */
349
+ getStructuredContext(options: {
350
+ limit?: number;
351
+ includeDeltas?: boolean;
352
+ includeSummary?: boolean;
353
+ } = {}): object {
354
+ const limit = options.limit || 10;
355
+ const recent = this.events.slice(-limit);
356
+ const now = Date.now();
357
+
358
+ if (recent.length === 0) {
359
+ return {
360
+ context: { app: "unknown", activity: "unknown" },
361
+ events: [],
362
+ deltas: options.includeDeltas ? [] : undefined,
363
+ };
364
+ }
365
+
366
+ const latest = recent[recent.length - 1];
367
+
368
+ return {
369
+ context: {
370
+ app: latest.semantic?.context?.app || latest.meta.app || "unknown",
371
+ window: latest.semantic?.context?.window || latest.meta.windowTitle || "",
372
+ activity: latest.semantic?.context?.activity || "unknown",
373
+ duration_s: latest.semantic?.context?.duration_s || 0,
374
+ },
375
+ events: recent.map(e => ({
376
+ id: e.id,
377
+ ago_s: Math.round((now - e.receivedAt) / 1000),
378
+ activity: e.semantic?.context?.activity || e.type,
379
+ has_error: e.semantic?.visible?.has_error,
380
+ })),
381
+ visible: options.includeSummary ? {
382
+ summary: latest.semantic?.visible?.summary,
383
+ has_error: latest.semantic?.visible?.has_error,
384
+ has_unsaved: latest.semantic?.visible?.has_unsaved,
385
+ } : undefined,
386
+ deltas: options.includeDeltas ? this._accumulatedDeltas.slice(-20) : undefined,
387
+ meta: {
388
+ ts: now,
389
+ event_count: recent.length,
390
+ },
391
+ };
392
+ }
393
+
394
+ /** Current number of events. */
395
+ get size(): number {
396
+ return this.events.length;
397
+ }
398
+
399
+ /** Monotonically increasing version — bumps on every push. */
400
+ get version(): number {
401
+ return this._version;
402
+ }
403
+
404
+ /** Clear all events and accumulated deltas. */
405
+ clear(): void {
406
+ this.events = [];
407
+ this._accumulatedDeltas = [];
408
+ this._activityCounts.clear();
409
+ this._version++;
410
+ }
411
+
412
+ /** Strip imageData from events beyond the most recent maxImagesKept. */
413
+ private trimImages(): void {
414
+ let imagesFound = 0;
415
+ for (let i = this.events.length - 1; i >= 0; i--) {
416
+ if (this.events[i].imageData) {
417
+ imagesFound++;
418
+ if (imagesFound > this.maxImagesKept) {
419
+ delete this.events[i].imageData;
420
+ delete this.events[i].imageBbox;
421
+ }
422
+ }
423
+ }
424
+ }
425
+ }
@@ -0,0 +1,245 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { resolve, dirname } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import os from "node:os";
5
+ import type { CoreConfig, AudioPipelineConfig, TranscriptionConfig, AgentConfig, EscalationConfig, OpenClawConfig, EscalationMode, EscalationTransport, LearningConfig, TraitConfig, PrivacyConfig, PrivacyMatrix, PrivacyLevel, PrivacyRow } from "./types.js";
6
+ import { PRESETS } from "./privacy/presets.js";
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+
10
+ function loadDotEnv(): void {
11
+ // Try sinain-core/.env first, then project root .env
12
+ const candidates = [
13
+ resolve(__dirname, "..", ".env"),
14
+ resolve(__dirname, "..", "..", ".env"),
15
+ ];
16
+ for (const envPath of candidates) {
17
+ if (!existsSync(envPath)) continue;
18
+ try {
19
+ const raw = readFileSync(envPath, "utf-8");
20
+ for (const line of raw.split("\n")) {
21
+ const trimmed = line.trim();
22
+ if (!trimmed || trimmed.startsWith("#")) continue;
23
+ const eq = trimmed.indexOf("=");
24
+ if (eq < 1) continue;
25
+ const key = trimmed.slice(0, eq).trim();
26
+ let val = trimmed.slice(eq + 1).trim();
27
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
28
+ val = val.slice(1, -1);
29
+ } else {
30
+ // Strip inline comments (# preceded by whitespace) for unquoted values
31
+ const ci = val.search(/\s+#/);
32
+ if (ci !== -1) val = val.slice(0, ci).trimEnd();
33
+ }
34
+ if (!process.env[key]) {
35
+ process.env[key] = val;
36
+ }
37
+ }
38
+ console.log(`[config] loaded ${envPath}`);
39
+ return;
40
+ } catch { /* ignore */ }
41
+ }
42
+ }
43
+
44
+ loadDotEnv();
45
+
46
+ function env(key: string, fallback: string): string {
47
+ return process.env[key] || fallback;
48
+ }
49
+
50
+ function intEnv(key: string, fallback: number): number {
51
+ const v = process.env[key];
52
+ return v ? parseInt(v, 10) : fallback;
53
+ }
54
+
55
+ function floatEnv(key: string, fallback: number): number {
56
+ const v = process.env[key];
57
+ return v ? parseFloat(v) : fallback;
58
+ }
59
+
60
+ function boolEnv(key: string, fallback: boolean): boolean {
61
+ const v = process.env[key];
62
+ if (!v) return fallback;
63
+ return v === "true";
64
+ }
65
+
66
+ function resolvePath(p: string): string {
67
+ if (process.platform === "win32") {
68
+ // Expand %APPDATA%, %USERPROFILE%, %TEMP% etc.
69
+ p = p.replace(/%([^%]+)%/g, (_, key) => process.env[key] || "");
70
+ }
71
+ return p.replace(/^~/, os.homedir());
72
+ }
73
+
74
+ /** Return platform-appropriate default data directory. */
75
+ function sinainDataDir(): string {
76
+ if (process.platform === "win32") {
77
+ const appData = process.env.APPDATA || resolve(os.homedir(), "AppData", "Roaming");
78
+ return resolve(appData, "sinain");
79
+ }
80
+ return resolve(os.homedir(), ".sinain-core");
81
+ }
82
+
83
+ function sinainCaptureDir(): string {
84
+ if (process.platform === "win32") {
85
+ const appData = process.env.APPDATA || resolve(os.homedir(), "AppData", "Roaming");
86
+ return resolve(appData, "sinain", "capture");
87
+ }
88
+ return resolve(os.homedir(), ".sinain", "capture");
89
+ }
90
+
91
+
92
+ function loadPrivacyConfig(): PrivacyConfig {
93
+ const mode = env("PRIVACY_MODE", "off");
94
+
95
+ // Start with preset (default to "off" which is ALL_FULL)
96
+ const baseMatrix: PrivacyMatrix = PRESETS[mode] ? { ...PRESETS[mode] } : { ...PRESETS["off"] };
97
+
98
+ // Data type env var name → matrix key
99
+ const DATA_TYPE_MAP: Record<string, keyof PrivacyMatrix> = {
100
+ AUDIO: "audio_transcript",
101
+ OCR: "screen_ocr",
102
+ IMAGES: "screen_images",
103
+ TITLES: "window_titles",
104
+ CREDENTIALS: "credentials",
105
+ METADATA: "metadata",
106
+ };
107
+
108
+ // Destination env var name → row key
109
+ const DEST_MAP: Record<string, keyof PrivacyRow> = {
110
+ LOCAL_BUFFER: "local_buffer",
111
+ LOCAL_LLM: "local_llm",
112
+ TRIPLE_STORE: "triple_store",
113
+ OPENROUTER: "openrouter",
114
+ AGENT_GATEWAY: "agent_gateway",
115
+ };
116
+
117
+ // Allow per-cell overrides: PRIVACY_<DATA>_<DEST>=<level>
118
+ for (const [dtKey, dtField] of Object.entries(DATA_TYPE_MAP)) {
119
+ for (const [destKey, destField] of Object.entries(DEST_MAP)) {
120
+ const envKey = `PRIVACY_${dtKey}_${destKey}`;
121
+ const val = process.env[envKey];
122
+ if (val && ["full", "redacted", "summary", "none"].includes(val)) {
123
+ baseMatrix[dtField][destField] = val as PrivacyLevel;
124
+ }
125
+ }
126
+ }
127
+
128
+ return { mode, matrix: baseMatrix };
129
+ }
130
+
131
+ export function loadConfig(): CoreConfig {
132
+ const audioConfig: AudioPipelineConfig = {
133
+ device: env("AUDIO_DEVICE", "BlackHole 2ch"),
134
+ sampleRate: intEnv("AUDIO_SAMPLE_RATE", 16000),
135
+ channels: 1,
136
+ chunkDurationMs: intEnv("AUDIO_CHUNK_MS", 5000),
137
+ vadEnabled: boolEnv("AUDIO_VAD_ENABLED", true),
138
+ vadThreshold: floatEnv("AUDIO_VAD_THRESHOLD", 0.001),
139
+ captureCommand: env("AUDIO_CAPTURE_CMD", "screencapturekit") as "sox" | "ffmpeg" | "screencapturekit",
140
+ autoStart: boolEnv("AUDIO_AUTO_START", true),
141
+ gainDb: intEnv("AUDIO_GAIN_DB", 20),
142
+ };
143
+
144
+ const micEnabled = boolEnv("MIC_ENABLED", false);
145
+ const micConfig: AudioPipelineConfig = {
146
+ device: env("MIC_DEVICE", "default"),
147
+ sampleRate: intEnv("MIC_SAMPLE_RATE", 16000),
148
+ channels: 1,
149
+ chunkDurationMs: intEnv("MIC_CHUNK_MS", 5000),
150
+ vadEnabled: boolEnv("MIC_VAD_ENABLED", true),
151
+ vadThreshold: floatEnv("MIC_VAD_THRESHOLD", 0.008),
152
+ captureCommand: env("MIC_CAPTURE_CMD", "sox") as "sox" | "ffmpeg" | "screencapturekit",
153
+ autoStart: boolEnv("MIC_AUTO_START", false),
154
+ gainDb: intEnv("MIC_GAIN_DB", 0),
155
+ };
156
+
157
+ const transcriptionConfig: TranscriptionConfig = {
158
+ backend: env("TRANSCRIPTION_BACKEND", "openrouter") as TranscriptionConfig["backend"],
159
+ openrouterApiKey: env("OPENROUTER_API_KEY", ""),
160
+ geminiModel: env("TRANSCRIPTION_MODEL", "google/gemini-2.5-flash"),
161
+ language: env("TRANSCRIPTION_LANGUAGE", "en-US"),
162
+ local: {
163
+ bin: env("LOCAL_WHISPER_BIN", "whisper-cli"),
164
+ modelPath: resolvePath(env("LOCAL_WHISPER_MODEL", "~/models/ggml-large-v3-turbo.bin")),
165
+ language: env("TRANSCRIPTION_LANGUAGE", "en-US"),
166
+ timeoutMs: intEnv("LOCAL_WHISPER_TIMEOUT_MS", 15000),
167
+ },
168
+ };
169
+
170
+ const agentConfig: AgentConfig = {
171
+ enabled: boolEnv("AGENT_ENABLED", true),
172
+ model: env("AGENT_MODEL", "google/gemini-2.5-flash-lite"),
173
+ visionModel: env("AGENT_VISION_MODEL", "google/gemini-2.5-flash"),
174
+ visionEnabled: boolEnv("AGENT_VISION_ENABLED", true),
175
+ openrouterApiKey: env("OPENROUTER_API_KEY", ""),
176
+ maxTokens: intEnv("AGENT_MAX_TOKENS", 800),
177
+ temperature: floatEnv("AGENT_TEMPERATURE", 0.3),
178
+ pushToFeed: boolEnv("AGENT_PUSH_TO_FEED", true),
179
+ debounceMs: intEnv("AGENT_DEBOUNCE_MS", 3000),
180
+ maxIntervalMs: intEnv("AGENT_MAX_INTERVAL_MS", 30000),
181
+ cooldownMs: intEnv("AGENT_COOLDOWN_MS", 10000),
182
+ maxAgeMs: intEnv("AGENT_MAX_AGE_MS", 120000),
183
+ fallbackModels: env("AGENT_FALLBACK_MODELS", "google/gemini-2.5-flash,anthropic/claude-3.5-haiku")
184
+ .split(",").map(s => s.trim()).filter(Boolean),
185
+ historyLimit: intEnv("AGENT_HISTORY_LIMIT", 50),
186
+ };
187
+
188
+ const escalationMode = env("ESCALATION_MODE", "rich") as EscalationMode;
189
+ const escalationConfig: EscalationConfig = {
190
+ mode: escalationMode,
191
+ cooldownMs: intEnv("ESCALATION_COOLDOWN_MS", 30000),
192
+ staleMs: intEnv("ESCALATION_STALE_MS", 90000),
193
+ transport: env("ESCALATION_TRANSPORT", "auto") as EscalationTransport,
194
+ };
195
+
196
+ const openclawConfig: OpenClawConfig = {
197
+ gatewayWsUrl: env("OPENCLAW_WS_URL", env("OPENCLAW_GATEWAY_WS_URL", "ws://localhost:18789")),
198
+ gatewayToken: env("OPENCLAW_WS_TOKEN", env("OPENCLAW_GATEWAY_TOKEN", "")),
199
+ hookUrl: env("OPENCLAW_HTTP_URL", env("OPENCLAW_HOOK_URL", "http://localhost:18789/hooks/agent")),
200
+ hookToken: env("OPENCLAW_HTTP_TOKEN", env("OPENCLAW_HOOK_TOKEN", "")),
201
+ sessionKey: env("OPENCLAW_SESSION_KEY", "agent:main:sinain"),
202
+ phase1TimeoutMs: intEnv("OPENCLAW_PHASE1_TIMEOUT_MS", 30_000),
203
+ phase2TimeoutMs: intEnv("OPENCLAW_PHASE2_TIMEOUT_MS", 120_000),
204
+ pingIntervalMs: intEnv("OPENCLAW_PING_INTERVAL_MS", 30_000),
205
+ };
206
+
207
+ const situationDir = env("OPENCLAW_WORKSPACE_DIR", "~/.openclaw/workspace");
208
+ const situationMdPath = resolvePath(
209
+ env("SITUATION_MD_PATH", `${situationDir}/SITUATION.md`)
210
+ );
211
+
212
+ const defaultFeedbackDir = resolve(sinainDataDir(), "feedback");
213
+ const learningConfig: LearningConfig = {
214
+ enabled: boolEnv("LEARNING_ENABLED", true),
215
+ feedbackDir: resolvePath(env("FEEDBACK_DIR", defaultFeedbackDir)),
216
+ retentionDays: intEnv("FEEDBACK_RETENTION_DAYS", 30),
217
+ };
218
+
219
+ const traitConfig: TraitConfig = {
220
+ enabled: boolEnv("TRAITS_ENABLED", false),
221
+ configPath: resolvePath(env("TRAITS_CONFIG", "~/.sinain/traits.json")),
222
+ entropyHigh: boolEnv("TRAIT_ENTROPY_HIGH", false),
223
+ logDir: resolvePath(env("TRAIT_LOG_DIR", "~/.sinain-core/traits")),
224
+ };
225
+
226
+ const privacyConfig = loadPrivacyConfig();
227
+
228
+ return {
229
+ port: intEnv("PORT", 9500),
230
+ audioConfig,
231
+ audioAltDevice: env("AUDIO_ALT_DEVICE", "BlackHole 2ch"),
232
+ micConfig,
233
+ micEnabled,
234
+ transcriptionConfig,
235
+ agentConfig,
236
+ escalationConfig,
237
+ openclawConfig,
238
+ situationMdPath,
239
+ traceEnabled: boolEnv("TRACE_ENABLED", true),
240
+ traceDir: resolvePath(env("TRACE_DIR", resolve(sinainDataDir(), "traces"))),
241
+ learningConfig,
242
+ traitConfig,
243
+ privacyConfig,
244
+ };
245
+ }