@geravant/sinain 1.0.18 → 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.
- package/README.md +10 -1
- package/cli.js +176 -0
- package/index.ts +163 -1257
- package/install.js +12 -2
- package/launcher.js +622 -0
- package/openclaw.plugin.json +4 -0
- package/pack-prepare.js +48 -0
- package/package.json +26 -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 +80 -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 +812 -0
- package/sinain-core/src/escalation/message-builder.ts +323 -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 +507 -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 +417 -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/adapters/generic/adapter.ts +103 -0
- package/sinain-knowledge/adapters/interface.ts +72 -0
- package/sinain-knowledge/adapters/openclaw/adapter.ts +223 -0
- package/sinain-knowledge/curation/engine.ts +493 -0
- package/sinain-knowledge/curation/resilience.ts +336 -0
- package/sinain-knowledge/data/git-store.ts +312 -0
- package/sinain-knowledge/data/schema.ts +89 -0
- package/sinain-knowledge/data/snapshot.ts +226 -0
- package/sinain-knowledge/data/store.ts +488 -0
- package/sinain-knowledge/deploy/cli.ts +214 -0
- package/sinain-knowledge/deploy/manifest.ts +80 -0
- package/sinain-knowledge/protocol/bindings/generic.md +5 -0
- package/sinain-knowledge/protocol/bindings/openclaw.md +5 -0
- package/sinain-knowledge/protocol/heartbeat.md +62 -0
- package/sinain-knowledge/protocol/renderer.ts +56 -0
- package/sinain-knowledge/protocol/skill.md +335 -0
- package/sinain-mcp-server/index.ts +337 -0
- package/sinain-mcp-server/package.json +19 -0
- 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
|
+
}
|