@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,404 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import type { FeedBuffer } from "../buffers/feed-buffer.js";
|
|
3
|
+
import type { SenseBuffer } from "../buffers/sense-buffer.js";
|
|
4
|
+
import type { AgentConfig, AgentEntry, ContextWindow, EscalationMode, ContextRichness, RecorderStatus } from "../types.js";
|
|
5
|
+
import type { Profiler } from "../profiler.js";
|
|
6
|
+
import { buildContextWindow } from "./context-window.js";
|
|
7
|
+
import { analyzeContext } from "./analyzer.js";
|
|
8
|
+
import { writeSituationMd } from "./situation-writer.js";
|
|
9
|
+
import { calculateEscalationScore } from "../escalation/scorer.js";
|
|
10
|
+
import { log, warn, error, debug } from "../log.js";
|
|
11
|
+
import type { TraitEngine, TraitSelection } from "./traits.js";
|
|
12
|
+
import { writeTraitLog } from "./traits.js";
|
|
13
|
+
|
|
14
|
+
const TAG = "agent";
|
|
15
|
+
|
|
16
|
+
export interface AgentLoopDeps {
|
|
17
|
+
feedBuffer: FeedBuffer;
|
|
18
|
+
senseBuffer: SenseBuffer;
|
|
19
|
+
agentConfig: AgentConfig;
|
|
20
|
+
escalationMode: EscalationMode;
|
|
21
|
+
situationMdPath: string;
|
|
22
|
+
/** Called after analysis with digest + context for escalation check. */
|
|
23
|
+
onAnalysis: (entry: AgentEntry, contextWindow: ContextWindow) => void;
|
|
24
|
+
/** Called to broadcast HUD line to overlay. */
|
|
25
|
+
onHudUpdate: (text: string) => void;
|
|
26
|
+
/** Optional: tracer to record spans. */
|
|
27
|
+
onTraceStart?: (tickId: number) => TraceContext | null;
|
|
28
|
+
/** Optional: get current recorder status for prompt injection. */
|
|
29
|
+
getRecorderStatus?: () => RecorderStatus | null;
|
|
30
|
+
/** Optional: profiler for metrics collection. */
|
|
31
|
+
profiler?: Profiler;
|
|
32
|
+
/** Called after each successful SITUATION.md write with the content string. */
|
|
33
|
+
onSituationUpdate?: (content: string) => void;
|
|
34
|
+
/** Optional trait engine for personality voice selection. */
|
|
35
|
+
traitEngine?: TraitEngine;
|
|
36
|
+
/** Directory to write per-day trait log JSONL files. */
|
|
37
|
+
traitLogDir?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface TraceContext {
|
|
41
|
+
startSpan(name: string): void;
|
|
42
|
+
endSpan(attrs?: Record<string, unknown>): void;
|
|
43
|
+
finish(metrics: Record<string, unknown>): void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Map escalation mode to context richness. */
|
|
47
|
+
function modeToRichness(mode: EscalationMode): ContextRichness {
|
|
48
|
+
switch (mode) {
|
|
49
|
+
case "selective": return "lean";
|
|
50
|
+
case "focus": return "standard";
|
|
51
|
+
case "rich": return "rich";
|
|
52
|
+
default: return "standard";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Event-driven agent analysis loop.
|
|
58
|
+
*
|
|
59
|
+
* Replaces relay's setInterval(agentTick, 30000) + debounce with:
|
|
60
|
+
* - context:sense or context:audio event → debounce 3s → run analysis
|
|
61
|
+
* - Max interval 30s (forced tick if no events)
|
|
62
|
+
* - Cooldown 10s (don't re-analyze within 10s of last run)
|
|
63
|
+
*
|
|
64
|
+
* This cuts worst-case latency from ~60s to ~15s.
|
|
65
|
+
*/
|
|
66
|
+
export class AgentLoop extends EventEmitter {
|
|
67
|
+
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
68
|
+
private maxIntervalTimer: ReturnType<typeof setInterval> | null = null;
|
|
69
|
+
private lastRunTs = 0;
|
|
70
|
+
private running = false;
|
|
71
|
+
private started = false;
|
|
72
|
+
|
|
73
|
+
private lastPushedHud = "";
|
|
74
|
+
private agentNextId = 1;
|
|
75
|
+
private agentBuffer: AgentEntry[] = [];
|
|
76
|
+
private latestDigest: AgentEntry | null = null;
|
|
77
|
+
private lastTickFeedVersion = 0;
|
|
78
|
+
private lastTickSenseVersion = 0;
|
|
79
|
+
|
|
80
|
+
private stats = {
|
|
81
|
+
totalCalls: 0,
|
|
82
|
+
totalTokensIn: 0,
|
|
83
|
+
totalTokensOut: 0,
|
|
84
|
+
lastAnalysisTs: 0,
|
|
85
|
+
idleSkips: 0,
|
|
86
|
+
parseSuccesses: 0,
|
|
87
|
+
parseFailures: 0,
|
|
88
|
+
consecutiveIdenticalHud: 0,
|
|
89
|
+
hudChanges: 0,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
constructor(private deps: AgentLoopDeps) {
|
|
93
|
+
super();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Start the agent loop. */
|
|
97
|
+
start(): void {
|
|
98
|
+
if (this.started) return;
|
|
99
|
+
if (!this.deps.agentConfig.enabled || !this.deps.agentConfig.openrouterApiKey) {
|
|
100
|
+
if (this.deps.agentConfig.enabled) {
|
|
101
|
+
warn(TAG, "AGENT_ENABLED=true but OPENROUTER_API_KEY not set \u2014 agent disabled");
|
|
102
|
+
}
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
this.started = true;
|
|
107
|
+
// Max interval: forced tick every maxIntervalMs even if no events
|
|
108
|
+
this.maxIntervalTimer = setInterval(() => {
|
|
109
|
+
if (!this.debounceTimer) {
|
|
110
|
+
this.run().catch(err => error(TAG, "max-interval tick error:", err.message));
|
|
111
|
+
}
|
|
112
|
+
}, this.deps.agentConfig.maxIntervalMs);
|
|
113
|
+
|
|
114
|
+
log(TAG, `loop started (debounce=${this.deps.agentConfig.debounceMs}ms, max=${this.deps.agentConfig.maxIntervalMs}ms, cooldown=${this.deps.agentConfig.cooldownMs}ms, model=${this.deps.agentConfig.model})`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Stop the agent loop. */
|
|
118
|
+
stop(): void {
|
|
119
|
+
if (!this.started) return;
|
|
120
|
+
this.started = false;
|
|
121
|
+
if (this.debounceTimer) { clearTimeout(this.debounceTimer); this.debounceTimer = null; }
|
|
122
|
+
if (this.maxIntervalTimer) { clearInterval(this.maxIntervalTimer); this.maxIntervalTimer = null; }
|
|
123
|
+
log(TAG, "loop stopped");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Signal that new context is available.
|
|
128
|
+
* Called by sense POST handler and transcription callback.
|
|
129
|
+
* Triggers debounced analysis.
|
|
130
|
+
*/
|
|
131
|
+
onNewContext(): void {
|
|
132
|
+
if (!this.started) return;
|
|
133
|
+
|
|
134
|
+
// Debounce: wait N ms after last event before running
|
|
135
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
136
|
+
this.debounceTimer = setTimeout(() => {
|
|
137
|
+
this.debounceTimer = null;
|
|
138
|
+
this.run().catch(err => error(TAG, "debounce tick error:", err.message));
|
|
139
|
+
}, this.deps.agentConfig.debounceMs);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Get agent results history (newest first). */
|
|
143
|
+
getHistory(limit = 10): AgentEntry[] {
|
|
144
|
+
return this.agentBuffer.slice(-limit).reverse();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Get latest digest. */
|
|
148
|
+
getDigest(): AgentEntry | null {
|
|
149
|
+
return this.latestDigest;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Get context window for debugging. */
|
|
153
|
+
getContext(): ContextWindow {
|
|
154
|
+
const richness = modeToRichness(this.deps.escalationMode);
|
|
155
|
+
return buildContextWindow(
|
|
156
|
+
this.deps.feedBuffer,
|
|
157
|
+
this.deps.senseBuffer,
|
|
158
|
+
richness,
|
|
159
|
+
this.deps.agentConfig.maxAgeMs,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Get config (safe — no API key). */
|
|
164
|
+
getConfig(): Record<string, unknown> {
|
|
165
|
+
const { openrouterApiKey, ...safe } = this.deps.agentConfig;
|
|
166
|
+
return { ...safe, hasApiKey: !!openrouterApiKey, escalationMode: this.deps.escalationMode };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Get stats for /health. */
|
|
170
|
+
getStats(): Record<string, unknown> {
|
|
171
|
+
const costPerToken = { in: 0.075 / 1_000_000, out: 0.3 / 1_000_000 };
|
|
172
|
+
const estimatedCost =
|
|
173
|
+
this.stats.totalTokensIn * costPerToken.in +
|
|
174
|
+
this.stats.totalTokensOut * costPerToken.out;
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
enabled: this.deps.agentConfig.enabled,
|
|
178
|
+
lastAnalysis: this.stats.lastAnalysisTs || null,
|
|
179
|
+
lastDigest: this.latestDigest?.digest?.slice(0, 200) || null,
|
|
180
|
+
totalCalls: this.stats.totalCalls,
|
|
181
|
+
totalTokens: { in: this.stats.totalTokensIn, out: this.stats.totalTokensOut },
|
|
182
|
+
estimatedCost: Math.round(estimatedCost * 1000000) / 1000000,
|
|
183
|
+
model: this.deps.agentConfig.model,
|
|
184
|
+
idleSkips: this.stats.idleSkips,
|
|
185
|
+
parseSuccessRate: this.stats.parseSuccesses + this.stats.parseFailures > 0
|
|
186
|
+
? Math.round((this.stats.parseSuccesses / (this.stats.parseSuccesses + this.stats.parseFailures)) * 100)
|
|
187
|
+
: null,
|
|
188
|
+
hudChangeRate: this.stats.hudChanges,
|
|
189
|
+
consecutiveIdenticalHud: this.stats.consecutiveIdenticalHud,
|
|
190
|
+
debounceMs: this.deps.agentConfig.debounceMs,
|
|
191
|
+
fallbackModels: this.deps.agentConfig.fallbackModels,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Update config at runtime. */
|
|
196
|
+
updateConfig(updates: Record<string, unknown>): void {
|
|
197
|
+
const c = this.deps.agentConfig;
|
|
198
|
+
if (updates.enabled !== undefined) c.enabled = !!updates.enabled;
|
|
199
|
+
if (updates.model !== undefined) c.model = String(updates.model);
|
|
200
|
+
if (updates.maxTokens !== undefined) c.maxTokens = Math.max(100, parseInt(String(updates.maxTokens)));
|
|
201
|
+
if (updates.temperature !== undefined) c.temperature = parseFloat(String(updates.temperature));
|
|
202
|
+
if (updates.pushToFeed !== undefined) c.pushToFeed = !!updates.pushToFeed;
|
|
203
|
+
if (updates.debounceMs !== undefined) c.debounceMs = Math.max(1000, parseInt(String(updates.debounceMs)));
|
|
204
|
+
if (updates.maxIntervalMs !== undefined) c.maxIntervalMs = Math.max(5000, parseInt(String(updates.maxIntervalMs)));
|
|
205
|
+
if (updates.cooldownMs !== undefined) c.cooldownMs = Math.max(3000, parseInt(String(updates.cooldownMs)));
|
|
206
|
+
if (updates.fallbackModels !== undefined) c.fallbackModels = Array.isArray(updates.fallbackModels) ? updates.fallbackModels : [];
|
|
207
|
+
if (updates.openrouterApiKey !== undefined) c.openrouterApiKey = String(updates.openrouterApiKey);
|
|
208
|
+
|
|
209
|
+
// Restart loop if needed
|
|
210
|
+
if (c.enabled && c.openrouterApiKey) {
|
|
211
|
+
if (!this.started) this.start();
|
|
212
|
+
else {
|
|
213
|
+
// Reset max interval timer with new config
|
|
214
|
+
this.stop();
|
|
215
|
+
this.start();
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
this.stop();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── Private: run a single analysis tick ──
|
|
223
|
+
|
|
224
|
+
private async run(): Promise<void> {
|
|
225
|
+
if (this.running) return;
|
|
226
|
+
if (!this.deps.agentConfig.openrouterApiKey) return;
|
|
227
|
+
|
|
228
|
+
// Cooldown: don't re-analyze within cooldownMs of last run
|
|
229
|
+
if (Date.now() - this.lastRunTs < this.deps.agentConfig.cooldownMs) return;
|
|
230
|
+
|
|
231
|
+
// Idle suppression: skip if no new events since last tick
|
|
232
|
+
const { feedBuffer, senseBuffer } = this.deps;
|
|
233
|
+
if (feedBuffer.version === this.lastTickFeedVersion &&
|
|
234
|
+
senseBuffer.version === this.lastTickSenseVersion) {
|
|
235
|
+
this.stats.idleSkips++;
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
this.lastTickFeedVersion = feedBuffer.version;
|
|
239
|
+
this.lastTickSenseVersion = senseBuffer.version;
|
|
240
|
+
|
|
241
|
+
// Quick idle check BEFORE building context (saves ~20% context builds during idle)
|
|
242
|
+
const cutoff = Date.now() - this.deps.agentConfig.maxAgeMs;
|
|
243
|
+
const feedAudioCount = feedBuffer.queryBySource("audio", cutoff).length;
|
|
244
|
+
const screenCount = senseBuffer.queryByTime(cutoff).length;
|
|
245
|
+
if (feedAudioCount === 0 && screenCount === 0) {
|
|
246
|
+
this.stats.idleSkips++;
|
|
247
|
+
this.deps.profiler?.gauge("agent.idleSkips", this.stats.idleSkips);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const richness = modeToRichness(this.deps.escalationMode);
|
|
252
|
+
const ctxStart = Date.now();
|
|
253
|
+
const contextWindow = buildContextWindow(
|
|
254
|
+
feedBuffer, senseBuffer, richness, this.deps.agentConfig.maxAgeMs,
|
|
255
|
+
);
|
|
256
|
+
this.deps.profiler?.timerRecord("agent.contextBuild", Date.now() - ctxStart);
|
|
257
|
+
|
|
258
|
+
this.running = true;
|
|
259
|
+
const traceCtx = this.deps.onTraceStart?.(this.agentNextId) ?? null;
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
traceCtx?.startSpan("context-window");
|
|
263
|
+
traceCtx?.endSpan({ richness, screenEvents: contextWindow.screenCount, audioEntries: contextWindow.audioCount });
|
|
264
|
+
|
|
265
|
+
traceCtx?.startSpan("llm-call");
|
|
266
|
+
const recorderStatus = this.deps.getRecorderStatus?.() ?? null;
|
|
267
|
+
|
|
268
|
+
// Trait selection: pick the best personality voice for this tick
|
|
269
|
+
let traitSelection: TraitSelection | null = null;
|
|
270
|
+
if (this.deps.traitEngine?.enabled) {
|
|
271
|
+
const ocrText = contextWindow.screen.map(e => e.ocr ?? "").join(" ");
|
|
272
|
+
const audioText = contextWindow.audio.map(e => e.text).join(" ");
|
|
273
|
+
traitSelection = this.deps.traitEngine.selectTrait(ocrText, audioText);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const result = await analyzeContext(contextWindow, this.deps.agentConfig, recorderStatus);
|
|
277
|
+
this.deps.profiler?.timerRecord("agent.llmCall", result.latencyMs);
|
|
278
|
+
traceCtx?.endSpan({ model: result.model, tokensIn: result.tokensIn, tokensOut: result.tokensOut, latencyMs: result.latencyMs });
|
|
279
|
+
|
|
280
|
+
const { hud, digest, latencyMs, tokensIn, tokensOut, model: usedModel, parsedOk } = result;
|
|
281
|
+
|
|
282
|
+
// Track context freshness
|
|
283
|
+
const contextFreshness = contextWindow.newestEventTs
|
|
284
|
+
? Date.now() - contextWindow.newestEventTs
|
|
285
|
+
: null;
|
|
286
|
+
|
|
287
|
+
// Track HUD staleness
|
|
288
|
+
if (hud === this.lastPushedHud) {
|
|
289
|
+
this.stats.consecutiveIdenticalHud++;
|
|
290
|
+
} else {
|
|
291
|
+
this.stats.consecutiveIdenticalHud = 0;
|
|
292
|
+
this.stats.hudChanges++;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Update stats
|
|
296
|
+
this.stats.totalCalls++;
|
|
297
|
+
this.stats.totalTokensIn += tokensIn;
|
|
298
|
+
this.stats.totalTokensOut += tokensOut;
|
|
299
|
+
this.stats.lastAnalysisTs = Date.now();
|
|
300
|
+
this.deps.profiler?.gauge("agent.totalCalls", this.stats.totalCalls);
|
|
301
|
+
if (parsedOk) this.stats.parseSuccesses++;
|
|
302
|
+
else this.stats.parseFailures++;
|
|
303
|
+
this.deps.profiler?.gauge("agent.parseSuccesses", this.stats.parseSuccesses);
|
|
304
|
+
this.deps.profiler?.gauge("agent.parseFailures", this.stats.parseFailures);
|
|
305
|
+
|
|
306
|
+
// Build entry
|
|
307
|
+
const entry: AgentEntry = {
|
|
308
|
+
...result,
|
|
309
|
+
id: this.agentNextId++,
|
|
310
|
+
ts: Date.now(),
|
|
311
|
+
pushed: false,
|
|
312
|
+
contextFreshnessMs: contextFreshness,
|
|
313
|
+
context: {
|
|
314
|
+
currentApp: contextWindow.currentApp,
|
|
315
|
+
appHistory: contextWindow.appHistory.map(a => a.app),
|
|
316
|
+
audioCount: contextWindow.audioCount,
|
|
317
|
+
screenCount: contextWindow.screenCount,
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
if (traitSelection) {
|
|
321
|
+
entry.voice = traitSelection.trait.name;
|
|
322
|
+
entry.voice_stat = traitSelection.stat;
|
|
323
|
+
entry.voice_confidence = traitSelection.confidence;
|
|
324
|
+
}
|
|
325
|
+
this.agentBuffer.push(entry);
|
|
326
|
+
const historyLimit = this.deps.agentConfig.historyLimit || 50;
|
|
327
|
+
if (this.agentBuffer.length > historyLimit) this.agentBuffer.shift();
|
|
328
|
+
|
|
329
|
+
const imageCount = contextWindow.images?.length || 0;
|
|
330
|
+
if (hud !== this.lastPushedHud) {
|
|
331
|
+
log(TAG, `#${entry.id} (${latencyMs}ms, ${tokensIn}in+${tokensOut}out tok, model=${usedModel}, richness=${richness}, images=${imageCount}) hud="${hud}"`);
|
|
332
|
+
} else {
|
|
333
|
+
debug(TAG, `#${entry.id} (${latencyMs}ms) hud unchanged`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Push HUD line to feed (suppress "—", "Idle", and all in focus mode)
|
|
337
|
+
if (this.deps.agentConfig.pushToFeed &&
|
|
338
|
+
this.deps.escalationMode !== "focus" &&
|
|
339
|
+
this.deps.escalationMode !== "rich" &&
|
|
340
|
+
hud !== "\u2014" && hud !== "Idle" && hud !== this.lastPushedHud) {
|
|
341
|
+
feedBuffer.push(`[\ud83e\udde0] ${hud}`, "normal", "agent", "stream");
|
|
342
|
+
this.deps.onHudUpdate(`[\ud83e\udde0] ${hud}`);
|
|
343
|
+
this.lastPushedHud = hud;
|
|
344
|
+
entry.pushed = true;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Store digest
|
|
348
|
+
this.latestDigest = entry;
|
|
349
|
+
|
|
350
|
+
// Calculate escalation score for both SITUATION.md and escalation check
|
|
351
|
+
const escalationScore = calculateEscalationScore(digest, contextWindow);
|
|
352
|
+
|
|
353
|
+
// Write SITUATION.md (enhanced with escalation context and recorder status)
|
|
354
|
+
const situationContent = writeSituationMd(this.deps.situationMdPath, contextWindow, digest, entry, escalationScore, recorderStatus, traitSelection);
|
|
355
|
+
this.deps.onSituationUpdate?.(situationContent);
|
|
356
|
+
|
|
357
|
+
// Notify for escalation check
|
|
358
|
+
traceCtx?.startSpan("escalation-check");
|
|
359
|
+
this.deps.onAnalysis(entry, contextWindow);
|
|
360
|
+
traceCtx?.endSpan();
|
|
361
|
+
|
|
362
|
+
// Finish trace
|
|
363
|
+
const costPerToken = { in: 0.075 / 1_000_000, out: 0.3 / 1_000_000 };
|
|
364
|
+
traceCtx?.finish({
|
|
365
|
+
totalLatencyMs: Date.now() - entry.ts + latencyMs,
|
|
366
|
+
llmLatencyMs: latencyMs,
|
|
367
|
+
llmInputTokens: tokensIn,
|
|
368
|
+
llmOutputTokens: tokensOut,
|
|
369
|
+
llmCost: tokensIn * costPerToken.in + tokensOut * costPerToken.out,
|
|
370
|
+
escalated: false, // Updated by escalator
|
|
371
|
+
escalationScore: 0,
|
|
372
|
+
contextScreenEvents: contextWindow.screenCount,
|
|
373
|
+
contextAudioEntries: contextWindow.audioCount,
|
|
374
|
+
contextRichness: richness,
|
|
375
|
+
digestLength: digest.length,
|
|
376
|
+
hudChanged: entry.pushed,
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// Fire-and-forget trait log
|
|
380
|
+
if (this.deps.traitEngine?.enabled && this.deps.traitLogDir) {
|
|
381
|
+
writeTraitLog(this.deps.traitLogDir, {
|
|
382
|
+
ts: new Date().toISOString(),
|
|
383
|
+
tickId: entry.id,
|
|
384
|
+
enabled: true,
|
|
385
|
+
voice: traitSelection?.trait.name ?? "none",
|
|
386
|
+
voice_stat: traitSelection?.stat ?? 0,
|
|
387
|
+
voice_confidence: traitSelection?.confidence ?? 0,
|
|
388
|
+
activation_scores: traitSelection?.allScores ?? {},
|
|
389
|
+
context_app: contextWindow.currentApp,
|
|
390
|
+
hud_length: entry.hud.length,
|
|
391
|
+
synthesis: false,
|
|
392
|
+
}).catch(() => {});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
} catch (err: any) {
|
|
396
|
+
error(TAG, "tick error:", err.message || err);
|
|
397
|
+
traceCtx?.endSpan({ status: "error", error: err.message });
|
|
398
|
+
traceCtx?.finish({ totalLatencyMs: Date.now() - Date.now(), llmLatencyMs: 0, llmInputTokens: 0, llmOutputTokens: 0, llmCost: 0, escalated: false, escalationScore: 0, contextScreenEvents: 0, contextAudioEntries: 0, contextRichness: richness, digestLength: 0, hudChanged: false });
|
|
399
|
+
} finally {
|
|
400
|
+
this.running = false;
|
|
401
|
+
this.lastRunTs = Date.now();
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { ContextWindow, AgentEntry, RecorderStatus } from "../types.js";
|
|
4
|
+
import type { EscalationScore } from "../escalation/scorer.js";
|
|
5
|
+
import type { TraitSelection } from "./traits.js";
|
|
6
|
+
import { normalizeAppName } from "./context-window.js";
|
|
7
|
+
import { log, error } from "../log.js";
|
|
8
|
+
|
|
9
|
+
const TAG = "situation";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Error stack trace patterns for extraction.
|
|
13
|
+
*/
|
|
14
|
+
const ERROR_STACK_PATTERNS = [
|
|
15
|
+
/Error:.*\n(\s+at\s+.*\n)+/g, // JavaScript stack traces
|
|
16
|
+
/Traceback.*:\n(\s+File.*\n)+/gi, // Python tracebacks
|
|
17
|
+
/panic:.*\n(\s+goroutine.*\n)?/g, // Go panics
|
|
18
|
+
/Exception.*:\n(\s+at\s+.*\n)+/g, // Java exceptions
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Extract error stack traces from text.
|
|
23
|
+
*/
|
|
24
|
+
function extractErrors(text: string): string[] {
|
|
25
|
+
const errors: string[] = [];
|
|
26
|
+
for (const pattern of ERROR_STACK_PATTERNS) {
|
|
27
|
+
const matches = text.match(pattern);
|
|
28
|
+
if (matches) errors.push(...matches.map(m => m.slice(0, 500)));
|
|
29
|
+
}
|
|
30
|
+
return errors;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Atomically write SITUATION.md for OpenClaw bootstrap.
|
|
35
|
+
* Ported from relay's writeSituationMd() — uses write-then-rename for atomicity.
|
|
36
|
+
*
|
|
37
|
+
* Enhanced with:
|
|
38
|
+
* - Escalation context (score and reasons)
|
|
39
|
+
* - Detected errors section
|
|
40
|
+
* - Active recording status
|
|
41
|
+
*/
|
|
42
|
+
export function writeSituationMd(
|
|
43
|
+
situationMdPath: string,
|
|
44
|
+
contextWindow: ContextWindow,
|
|
45
|
+
digest: string,
|
|
46
|
+
entry: AgentEntry,
|
|
47
|
+
escalationScore?: EscalationScore,
|
|
48
|
+
recorderStatus?: RecorderStatus | null,
|
|
49
|
+
traitSelection?: TraitSelection | null,
|
|
50
|
+
): string {
|
|
51
|
+
const dir = path.dirname(situationMdPath);
|
|
52
|
+
const tmpPath = situationMdPath + ".tmp";
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
56
|
+
} catch (err: any) {
|
|
57
|
+
if (err.code !== "EEXIST") {
|
|
58
|
+
error(TAG, "mkdir failed:", err.message);
|
|
59
|
+
return "";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const now = new Date();
|
|
64
|
+
const lines: string[] = [];
|
|
65
|
+
|
|
66
|
+
lines.push("# Situation");
|
|
67
|
+
lines.push("");
|
|
68
|
+
lines.push(`> Auto-updated by sinain-core at ${now.toISOString()}`);
|
|
69
|
+
lines.push(`> Tick #${entry.id} | Latency: ${entry.latencyMs}ms | Model: ${entry.model}`);
|
|
70
|
+
lines.push("");
|
|
71
|
+
|
|
72
|
+
lines.push("## Digest");
|
|
73
|
+
lines.push("");
|
|
74
|
+
lines.push(digest);
|
|
75
|
+
lines.push("");
|
|
76
|
+
|
|
77
|
+
const currentApp = normalizeAppName(contextWindow.currentApp);
|
|
78
|
+
lines.push("## Active Application");
|
|
79
|
+
lines.push("");
|
|
80
|
+
lines.push(currentApp);
|
|
81
|
+
lines.push("");
|
|
82
|
+
|
|
83
|
+
if (contextWindow.appHistory.length > 0) {
|
|
84
|
+
lines.push("## App History");
|
|
85
|
+
lines.push("");
|
|
86
|
+
const appChain = contextWindow.appHistory
|
|
87
|
+
.map(a => normalizeAppName(a.app))
|
|
88
|
+
.join(" -> ");
|
|
89
|
+
lines.push(appChain);
|
|
90
|
+
lines.push("");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (contextWindow.screen.length > 0) {
|
|
94
|
+
lines.push("## Screen (OCR)");
|
|
95
|
+
lines.push("");
|
|
96
|
+
for (const e of contextWindow.screen) {
|
|
97
|
+
const app = normalizeAppName(e.meta.app);
|
|
98
|
+
const ago = Math.round((Date.now() - (e.ts || Date.now())) / 1000);
|
|
99
|
+
const ocr = e.ocr ? e.ocr.replace(/\n/g, " ").slice(0, 500) : "(no text)";
|
|
100
|
+
lines.push(`- [${ago}s ago] [${app}] ${ocr}`);
|
|
101
|
+
}
|
|
102
|
+
lines.push("");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (contextWindow.audio.length > 0) {
|
|
106
|
+
lines.push("## Audio Transcripts");
|
|
107
|
+
lines.push("");
|
|
108
|
+
for (const e of contextWindow.audio) {
|
|
109
|
+
const ago = Math.round((Date.now() - (e.ts || Date.now())) / 1000);
|
|
110
|
+
lines.push(`- [${ago}s ago] ${e.text.slice(0, 500)}`);
|
|
111
|
+
}
|
|
112
|
+
lines.push("");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Enhanced: Escalation context for richer understanding
|
|
116
|
+
if (escalationScore && escalationScore.total > 0) {
|
|
117
|
+
lines.push("## Escalation Context");
|
|
118
|
+
lines.push("");
|
|
119
|
+
lines.push(`- Score: ${escalationScore.total}`);
|
|
120
|
+
lines.push(`- Reasons: ${escalationScore.reasons.join(", ") || "none"}`);
|
|
121
|
+
lines.push("");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Enhanced: Detected errors section
|
|
125
|
+
const allText = [
|
|
126
|
+
digest,
|
|
127
|
+
...contextWindow.screen.map(e => e.ocr || ""),
|
|
128
|
+
...contextWindow.audio.map(e => e.text || ""),
|
|
129
|
+
].join("\n");
|
|
130
|
+
const detectedErrors = extractErrors(allText);
|
|
131
|
+
if (detectedErrors.length > 0) {
|
|
132
|
+
lines.push("## Detected Errors");
|
|
133
|
+
lines.push("");
|
|
134
|
+
for (const err of detectedErrors.slice(0, 3)) {
|
|
135
|
+
lines.push("```");
|
|
136
|
+
lines.push(err.trim());
|
|
137
|
+
lines.push("```");
|
|
138
|
+
lines.push("");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Enhanced: Active recording status
|
|
143
|
+
if (recorderStatus?.recording) {
|
|
144
|
+
lines.push("## Active Recording");
|
|
145
|
+
lines.push("");
|
|
146
|
+
const label = recorderStatus.label || "Unnamed recording";
|
|
147
|
+
const durationSec = Math.round(recorderStatus.durationMs / 1000);
|
|
148
|
+
lines.push(`- Label: ${label}`);
|
|
149
|
+
lines.push(`- Duration: ${durationSec}s`);
|
|
150
|
+
lines.push(`- Segments: ${recorderStatus.segments}`);
|
|
151
|
+
lines.push("");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Trait voice: frame downstream agent's perspective
|
|
155
|
+
if (traitSelection) {
|
|
156
|
+
const { trait, stat } = traitSelection;
|
|
157
|
+
const voiceFlavor = stat >= 7 ? trait.voice_high : stat <= 2 ? trait.voice_low : trait.description;
|
|
158
|
+
lines.push("## Active Voice");
|
|
159
|
+
lines.push("");
|
|
160
|
+
lines.push(`- Trait: ${trait.name} (stat ${stat}/10)`);
|
|
161
|
+
lines.push(`- Tagline: ${trait.tagline}`);
|
|
162
|
+
lines.push(`- Voice: "${voiceFlavor}"`);
|
|
163
|
+
lines.push("");
|
|
164
|
+
lines.push("> Frame your response through this lens naturally. Do not name the trait explicitly.");
|
|
165
|
+
lines.push("");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
lines.push("## Metadata");
|
|
169
|
+
lines.push("");
|
|
170
|
+
lines.push(`- Screen events in window: ${contextWindow.screenCount}`);
|
|
171
|
+
lines.push(`- Audio events in window: ${contextWindow.audioCount}`);
|
|
172
|
+
lines.push(`- Context window: ${Math.round(contextWindow.windowMs / 1000)}s`);
|
|
173
|
+
lines.push(`- Parsed OK: ${entry.parsedOk}`);
|
|
174
|
+
lines.push("");
|
|
175
|
+
|
|
176
|
+
const content = lines.join("\n");
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
fs.writeFileSync(tmpPath, content, "utf-8");
|
|
180
|
+
fs.renameSync(tmpPath, situationMdPath);
|
|
181
|
+
} catch (err: any) {
|
|
182
|
+
error(TAG, "write failed:", err.message);
|
|
183
|
+
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return content;
|
|
187
|
+
}
|