@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.
- package/README.md +10 -1
- package/cli.js +176 -0
- package/index.ts +4 -2
- package/install.js +89 -14
- package/launcher.js +622 -0
- package/openclaw.plugin.json +4 -0
- package/pack-prepare.js +48 -0
- package/package.json +24 -5
- package/sense_client/README.md +82 -0
- package/sense_client/__init__.py +1 -0
- package/sense_client/__main__.py +462 -0
- package/sense_client/app_detector.py +54 -0
- package/sense_client/app_detector_win.py +83 -0
- package/sense_client/capture.py +215 -0
- package/sense_client/capture_win.py +88 -0
- package/sense_client/change_detector.py +86 -0
- package/sense_client/config.py +64 -0
- package/sense_client/gate.py +145 -0
- package/sense_client/ocr.py +347 -0
- package/sense_client/privacy.py +65 -0
- package/sense_client/requirements.txt +13 -0
- package/sense_client/roi_extractor.py +84 -0
- package/sense_client/sender.py +173 -0
- package/sense_client/tests/__init__.py +0 -0
- package/sense_client/tests/test_stream1_optimizations.py +234 -0
- package/setup-overlay.js +82 -0
- package/sinain-agent/.env.example +17 -0
- package/sinain-agent/CLAUDE.md +87 -0
- package/sinain-agent/mcp-config.json +12 -0
- package/sinain-agent/run.sh +248 -0
- package/sinain-core/.env.example +93 -0
- package/sinain-core/package-lock.json +552 -0
- package/sinain-core/package.json +21 -0
- package/sinain-core/src/agent/analyzer.ts +366 -0
- package/sinain-core/src/agent/context-window.ts +172 -0
- package/sinain-core/src/agent/loop.ts +404 -0
- package/sinain-core/src/agent/situation-writer.ts +187 -0
- package/sinain-core/src/agent/traits.ts +520 -0
- package/sinain-core/src/audio/capture-spawner-macos.ts +44 -0
- package/sinain-core/src/audio/capture-spawner-win.ts +37 -0
- package/sinain-core/src/audio/capture-spawner.ts +14 -0
- package/sinain-core/src/audio/pipeline.ts +335 -0
- package/sinain-core/src/audio/transcription-local.ts +141 -0
- package/sinain-core/src/audio/transcription.ts +278 -0
- package/sinain-core/src/buffers/feed-buffer.ts +71 -0
- package/sinain-core/src/buffers/sense-buffer.ts +425 -0
- package/sinain-core/src/config.ts +245 -0
- package/sinain-core/src/escalation/escalation-slot.ts +136 -0
- package/sinain-core/src/escalation/escalator.ts +828 -0
- package/sinain-core/src/escalation/message-builder.ts +370 -0
- package/sinain-core/src/escalation/openclaw-ws.ts +726 -0
- package/sinain-core/src/escalation/scorer.ts +166 -0
- package/sinain-core/src/index.ts +537 -0
- package/sinain-core/src/learning/feedback-store.ts +253 -0
- package/sinain-core/src/learning/signal-collector.ts +218 -0
- package/sinain-core/src/log.ts +24 -0
- package/sinain-core/src/overlay/commands.ts +126 -0
- package/sinain-core/src/overlay/ws-handler.ts +267 -0
- package/sinain-core/src/privacy/index.ts +18 -0
- package/sinain-core/src/privacy/presets.ts +40 -0
- package/sinain-core/src/privacy/redact.ts +92 -0
- package/sinain-core/src/profiler.ts +181 -0
- package/sinain-core/src/recorder.ts +186 -0
- package/sinain-core/src/server.ts +456 -0
- package/sinain-core/src/trace/trace-store.ts +73 -0
- package/sinain-core/src/trace/tracer.ts +94 -0
- package/sinain-core/src/types.ts +427 -0
- package/sinain-core/src/util/dedup.ts +48 -0
- package/sinain-core/src/util/task-store.ts +84 -0
- package/sinain-core/tsconfig.json +18 -0
- package/sinain-knowledge/curation/engine.ts +137 -24
- package/sinain-knowledge/data/git-store.ts +26 -0
- package/sinain-knowledge/data/store.ts +117 -0
- package/sinain-mcp-server/index.ts +417 -0
- package/sinain-mcp-server/package.json +19 -0
- package/sinain-mcp-server/tsconfig.json +15 -0
- package/sinain-memory/graph_query.py +185 -0
- package/sinain-memory/knowledge_integrator.py +450 -0
- package/sinain-memory/memory-config.json +3 -1
- 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
|
+
}
|