@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,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
+ }