@geravant/sinain 1.8.0 → 1.9.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.
@@ -10,7 +10,7 @@ import { TranscriptionService } from "./audio/transcription.js";
10
10
  import { AgentLoop } from "./agent/loop.js";
11
11
  import { TraitEngine, loadTraitRoster } from "./agent/traits.js";
12
12
  import { shortAppName } from "./agent/context-window.js";
13
- import { Escalator, type SpawnContext } from "./escalation/escalator.js";
13
+ import { Escalator } from "./escalation/escalator.js";
14
14
  import { Recorder } from "./recorder.js";
15
15
  import { Tracer } from "./trace/tracer.js";
16
16
  import { TraceStore } from "./trace/trace-store.js";
@@ -18,6 +18,7 @@ import { FeedbackStore } from "./learning/feedback-store.js";
18
18
  import { SignalCollector } from "./learning/signal-collector.js";
19
19
  import { createAppServer } from "./server.js";
20
20
  import { Profiler } from "./profiler.js";
21
+ import { CostTracker } from "./cost/tracker.js";
21
22
  import type { SenseEvent, EscalationMode, FeedItem } from "./types.js";
22
23
  import { isDuplicateTranscript, bigramSimilarity } from "./util/dedup.js";
23
24
  import { log, warn, error } from "./log.js";
@@ -25,41 +26,6 @@ import { initPrivacy, levelFor, applyLevel } from "./privacy/index.js";
25
26
 
26
27
  const TAG = "core";
27
28
 
28
- /** Build context snapshot for user-initiated spawn tasks. */
29
- function buildSpawnContext(
30
- entry: { digest: string; context: { currentApp: string } },
31
- feedBuffer: FeedBuffer,
32
- senseBuffer: SenseBuffer,
33
- ): SpawnContext {
34
- const cutoff = Date.now() - 60_000;
35
-
36
- // Recent audio: last ~60s of transcripts
37
- const recentAudio = feedBuffer.queryByTime(cutoff)
38
- .filter(m => m.channel === "stream" && (m.text.startsWith("[🔊]") || m.text.startsWith("[🎙]")))
39
- .map(m => m.text)
40
- .join("\n")
41
- .slice(0, 2000);
42
-
43
- // Recent screen: last ~60s of deduped OCR text
44
- const screenEvents = senseBuffer.queryByTime(cutoff);
45
- const seenOcr = new Set<string>();
46
- const screenLines: string[] = [];
47
- for (const e of screenEvents) {
48
- if (e.ocr && !seenOcr.has(e.ocr)) {
49
- seenOcr.add(e.ocr);
50
- screenLines.push(`[${e.meta.app}] ${e.ocr}`);
51
- }
52
- }
53
- const recentScreen = screenLines.join("\n").slice(0, 3000);
54
-
55
- return {
56
- currentApp: entry.context.currentApp,
57
- digest: entry.digest,
58
- recentAudio: recentAudio || undefined,
59
- recentScreen: recentScreen || undefined,
60
- };
61
- }
62
-
63
29
  /** Resolve workspace path, expanding leading ~ to HOME. */
64
30
  function resolveWorkspace(): string {
65
31
  const raw = process.env.SINAIN_WORKSPACE || `${process.env.HOME}/.openclaw/workspace`;
@@ -93,6 +59,10 @@ async function main() {
93
59
  // ── Initialize overlay WS handler ──
94
60
  const wsHandler = new WsHandler();
95
61
 
62
+ // ── Initialize cost tracker ──
63
+ const costTracker = new CostTracker((snapshot) => wsHandler.broadcastCost(snapshot, config.costDisplayEnabled));
64
+ costTracker.startPeriodicLog(60_000);
65
+
96
66
  // ── Initialize tracing ──
97
67
  const tracer = config.traceEnabled ? new Tracer() : null;
98
68
  const traceStore = config.traceEnabled ? new TraceStore(config.traceDir) : null;
@@ -100,9 +70,6 @@ async function main() {
100
70
  // ── Initialize recorder ──
101
71
  const recorder = new Recorder();
102
72
 
103
- // ── Spawn context cache — updated every agent tick for user-initiated spawns ──
104
- let lastSpawnContext: SpawnContext | null = null;
105
-
106
73
  // ── Initialize profiler ──
107
74
  const profiler = new Profiler();
108
75
 
@@ -146,11 +113,35 @@ async function main() {
146
113
  getRecorderStatus: () => recorder.getStatus(),
147
114
  profiler,
148
115
  onAnalysis: (entry, contextWindow) => {
149
- // Handle recorder commands (recording start/stop still works)
150
- recorder.handleCommand(entry.record);
116
+ // Handle recorder commands
117
+ const stopResult = recorder.handleCommand(entry.record);
118
+
119
+ // Dispatch task via subagent spawn
120
+ if (entry.task || stopResult) {
121
+ let task: string;
122
+ let label: string | undefined;
123
+
124
+ if (stopResult && stopResult.segments > 0 && entry.task) {
125
+ // Recording stopped with explicit task instruction
126
+ task = `${entry.task}\n\n[Recording: "${stopResult.title}", ${stopResult.durationS}s]\n${stopResult.transcript}`;
127
+ label = stopResult.title;
128
+ } else if (stopResult && stopResult.segments > 0) {
129
+ // Recording stopped without explicit task — default to cleanup/summarize
130
+ task = `Clean up and summarize this recording transcript:\n\n[Recording: "${stopResult.title}", ${stopResult.durationS}s]\n${stopResult.transcript}`;
131
+ label = stopResult.title;
132
+ } else if (entry.task) {
133
+ // Standalone task without recording
134
+ task = entry.task;
135
+ } else {
136
+ task = "";
137
+ }
151
138
 
152
- // Cache context for user-initiated spawn commands (Shift+Enter)
153
- lastSpawnContext = buildSpawnContext(entry, feedBuffer, senseBuffer);
139
+ if (task) {
140
+ escalator.dispatchSpawnTask(task, label).catch(err => {
141
+ error(TAG, "spawn task dispatch error:", err);
142
+ });
143
+ }
144
+ }
154
145
 
155
146
  // Escalation continues as normal
156
147
  escalator.onAgentAnalysis(entry, contextWindow);
@@ -184,6 +175,7 @@ async function main() {
184
175
  return null;
185
176
  },
186
177
  feedbackStore: feedbackStore ?? undefined,
178
+ costTracker,
187
179
  });
188
180
 
189
181
  // ── Wire learning signal collector (needs agentLoop) ──
@@ -211,6 +203,7 @@ async function main() {
211
203
  systemAudioPipeline.setProfiler(profiler);
212
204
  if (micPipeline) micPipeline.setProfiler(profiler);
213
205
  transcription.setProfiler(profiler);
206
+ transcription.setCostTracker(costTracker);
214
207
 
215
208
  // Wire: audio chunks → transcription (both pipelines share the same transcription service)
216
209
  systemAudioPipeline.on("chunk", (chunk) => {
@@ -322,6 +315,7 @@ async function main() {
322
315
  senseBuffer,
323
316
  wsHandler,
324
317
  profiler,
318
+ costTracker,
325
319
  feedbackStore: feedbackStore ?? undefined,
326
320
  isScreenActive: () => screenActive,
327
321
 
@@ -447,7 +441,7 @@ async function main() {
447
441
 
448
442
  // Spawn background agent task (from HUD Shift+Enter or bare agent POST /spawn)
449
443
  onSpawnCommand: (text: string) => {
450
- escalator.dispatchSpawnTask(text, text.slice(0, 64), lastSpawnContext ?? undefined).catch((err) => {
444
+ escalator.dispatchSpawnTask(text, "user-command").catch((err) => {
451
445
  log("srv", `spawn via HTTP failed: ${err}`);
452
446
  });
453
447
  },
@@ -475,7 +469,7 @@ async function main() {
475
469
  agentLoop.onNewContext(true);
476
470
  },
477
471
  onSpawnCommand: (text) => {
478
- escalator.dispatchSpawnTask(text, text.slice(0, 64), lastSpawnContext ?? undefined).catch((err) => {
472
+ escalator.dispatchSpawnTask(text, "user-command").catch((err) => {
479
473
  log("cmd", `spawn command failed: ${err}`);
480
474
  wsHandler.broadcast(`\u26a0 Spawn failed: ${String(err).slice(0, 100)}`, "normal");
481
475
  });
@@ -551,12 +545,14 @@ async function main() {
551
545
  log(TAG, ` agent: ${config.agentConfig.enabled ? "enabled" : "disabled"}`);
552
546
  log(TAG, ` escal: ${config.escalationConfig.mode}`);
553
547
  log(TAG, ` traits: ${config.traitConfig.enabled ? "enabled" : "disabled"} (${traitRoster.length} traits)`);
548
+ log(TAG, ` cost: display=${config.costDisplayEnabled ? "on" : "off"} (always logged)`);
554
549
 
555
550
  // ── Graceful shutdown ──
556
551
  const shutdown = async (signal: string) => {
557
552
  log(TAG, `${signal} received, shutting down...`);
558
553
  clearInterval(bufferGaugeTimer);
559
554
  if (feedbackSummaryTimer) clearInterval(feedbackSummaryTimer);
555
+ costTracker.stop();
560
556
  profiler.stop();
561
557
  recorder.forceStop(); // Stop any active recording
562
558
  agentLoop.stop();
@@ -1,8 +1,10 @@
1
+ import { execFile } from "node:child_process";
1
2
  import type { InboundMessage } from "../types.js";
2
3
  import type { WsHandler } from "./ws-handler.js";
3
4
  import type { AudioPipeline } from "../audio/pipeline.js";
4
5
  import type { CoreConfig } from "../types.js";
5
6
  import { WebSocket } from "ws";
7
+ import { loadedEnvPath } from "../config.js";
6
8
  import { log } from "../log.js";
7
9
 
8
10
  const TAG = "cmd";
@@ -60,15 +62,6 @@ export function setupCommands(deps: CommandDeps): void {
60
62
  case "spawn_command": {
61
63
  const preview = msg.text.length > 60 ? msg.text.slice(0, 60) + "…" : msg.text;
62
64
  log(TAG, `spawn command received: "${preview}"`);
63
- // Echo spawn command to all overlay clients as a feed item (green in UI)
64
- wsHandler.broadcastRaw({
65
- type: "feed",
66
- text: `⚡ ${msg.text}`,
67
- priority: "normal",
68
- ts: Date.now(),
69
- channel: "agent",
70
- sender: "spawn",
71
- } as any);
72
65
  if (deps.onSpawnCommand) {
73
66
  deps.onSpawnCommand(msg.text);
74
67
  } else {
@@ -151,6 +144,16 @@ function handleCommand(action: string, deps: CommandDeps): void {
151
144
  log(TAG, `traits toggled ${nowEnabled ? "ON" : "OFF"}`);
152
145
  break;
153
146
  }
147
+ case "open_settings": {
148
+ const envPath = loadedEnvPath || `${process.env.HOME || process.env.USERPROFILE}/.sinain/.env`;
149
+ const cmd = process.platform === "win32" ? "notepad" : "open";
150
+ const args = process.platform === "win32" ? [envPath] : ["-t", envPath];
151
+ execFile(cmd, args, (err) => {
152
+ if (err) log(TAG, `open_settings failed: ${err.message}`);
153
+ });
154
+ log(TAG, `open_settings: ${envPath}`);
155
+ break;
156
+ }
154
157
  default:
155
158
  log(TAG, `unhandled command: ${action}`);
156
159
  }
@@ -7,6 +7,8 @@ import type {
7
7
  FeedMessage,
8
8
  StatusMessage,
9
9
  SpawnTaskMessage,
10
+ CostMessage,
11
+ CostSnapshot,
10
12
  Priority,
11
13
  FeedChannel,
12
14
  } from "../types.js";
@@ -39,6 +41,7 @@ export class WsHandler {
39
41
  };
40
42
  private replayBuffer: FeedMessage[] = [];
41
43
  private spawnTaskBuffer: Map<string, SpawnTaskMessage> = new Map();
44
+ private latestCostMsg: CostMessage | null = null;
42
45
 
43
46
  constructor() {
44
47
  this.startHeartbeat();
@@ -89,6 +92,16 @@ export class WsHandler {
89
92
  log(TAG, `replayed ${this.spawnTaskBuffer.size} spawn tasks to new client`);
90
93
  }
91
94
 
95
+ // Replay current cost snapshot (or send zeroed snapshot so client resets)
96
+ this.sendTo(ws, this.latestCostMsg ?? {
97
+ type: "cost" as const,
98
+ totalCost: 0,
99
+ costBySource: {},
100
+ callCount: 0,
101
+ startedAt: Date.now(),
102
+ displayEnabled: false,
103
+ });
104
+
92
105
  ws.on("message", (raw) => {
93
106
  try {
94
107
  const data = JSON.parse(raw.toString()) as InboundMessage;
@@ -158,6 +171,20 @@ export class WsHandler {
158
171
  this.broadcastMessage(msg);
159
172
  }
160
173
 
174
+ /** Broadcast cost snapshot to all connected overlays. */
175
+ broadcastCost(snapshot: CostSnapshot, displayEnabled: boolean): void {
176
+ const msg: CostMessage = {
177
+ type: "cost",
178
+ totalCost: snapshot.totalCost,
179
+ costBySource: snapshot.costBySource,
180
+ callCount: snapshot.callCount,
181
+ startedAt: snapshot.startedAt,
182
+ displayEnabled,
183
+ };
184
+ this.latestCostMsg = msg;
185
+ this.broadcastMessage(msg);
186
+ }
187
+
161
188
  /** Update internal state and broadcast. */
162
189
  updateState(partial: Partial<BridgeState>): void {
163
190
  Object.assign(this.state, partial);
@@ -199,9 +226,6 @@ export class WsHandler {
199
226
  case "user_command":
200
227
  log(TAG, `\u2190 user command: ${msg.text.slice(0, 100)}`);
201
228
  break;
202
- case "spawn_command":
203
- log(TAG, `\u2190 spawn command: ${msg.text.slice(0, 100)}`);
204
- break;
205
229
  case "profiling":
206
230
  if (this.onProfilingCb) this.onProfilingCb(msg);
207
231
  return;
@@ -2,6 +2,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht
2
2
  import { WebSocketServer, WebSocket } from "ws";
3
3
  import type { CoreConfig, SenseEvent } from "./types.js";
4
4
  import type { Profiler } from "./profiler.js";
5
+ import type { CostTracker } from "./cost/tracker.js";
5
6
  import type { FeedbackStore } from "./learning/feedback-store.js";
6
7
  import { FeedBuffer } from "./buffers/feed-buffer.js";
7
8
  import { SenseBuffer, type SemanticSenseEvent, type TextDelta } from "./buffers/sense-buffer.js";
@@ -20,6 +21,7 @@ export interface ServerDeps {
20
21
  senseBuffer: SenseBuffer;
21
22
  wsHandler: WsHandler;
22
23
  profiler?: Profiler;
24
+ costTracker?: CostTracker;
23
25
  onSenseEvent: (event: SenseEvent) => void;
24
26
  onSenseDelta?: (data: { app: string; activity: string; changes: TextDelta[]; priority?: string; ts: number }) => void;
25
27
  onFeedPost: (text: string, priority: string) => void;
@@ -65,6 +67,8 @@ function readBody(req: IncomingMessage, maxBytes: number): Promise<string> {
65
67
  export function createAppServer(deps: ServerDeps) {
66
68
  const { config, feedBuffer, senseBuffer, wsHandler } = deps;
67
69
  let senseInBytes = 0;
70
+ const seenVisionCostIds = new Set<string>();
71
+ const visionCostCleanup = setInterval(() => seenVisionCostIds.clear(), 60_000);
68
72
 
69
73
  const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => {
70
74
  res.setHeader("Access-Control-Allow-Origin", "*");
@@ -91,6 +95,24 @@ export function createAppServer(deps: ServerDeps) {
91
95
  senseInBytes += Buffer.byteLength(body);
92
96
  deps.profiler?.gauge("network.senseInBytes", senseInBytes);
93
97
  const data = JSON.parse(body);
98
+
99
+ // Record vision API cost before dedup — the call is already billed.
100
+ // Dedup by cost_id to avoid double-counting on sender retries.
101
+ const vc = data.vision_cost;
102
+ if (vc && typeof vc.cost === "number" && vc.cost > 0) {
103
+ const costId = vc.cost_id as string | undefined;
104
+ if (!costId || !seenVisionCostIds.has(costId)) {
105
+ if (costId) seenVisionCostIds.add(costId);
106
+ deps.costTracker?.record({
107
+ source: "vision",
108
+ model: vc.model || "unknown",
109
+ cost: vc.cost,
110
+ tokensIn: vc.tokens_in || 0,
111
+ tokensOut: vc.tokens_out || 0,
112
+ ts: Date.now(),
113
+ });
114
+ }
115
+ }
94
116
  if (!data.type || data.ts === undefined) {
95
117
  res.writeHead(400);
96
118
  res.end(JSON.stringify({ ok: false, error: "missing type or ts" }));
@@ -309,6 +331,24 @@ export function createAppServer(deps: ServerDeps) {
309
331
  return;
310
332
  }
311
333
 
334
+ // ── /setup/status ── (used by overlay onboarding wizard)
335
+ if (req.method === "GET" && url.pathname === "/setup/status") {
336
+ const health = deps.getHealthPayload();
337
+ res.end(JSON.stringify({
338
+ ok: true,
339
+ setup: {
340
+ openrouterKey: !!config.transcriptionConfig.openrouterApiKey,
341
+ gatewayConfigured: !!config.openclawConfig.gatewayToken,
342
+ gatewayConnected: !!(health as Record<string, any>)?.escalation?.gatewayConnected,
343
+ audioActive: (health as Record<string, any>)?.audio?.state === "active" || (health as Record<string, any>)?.audioPipeline?.state === "running",
344
+ screenActive: deps.isScreenActive(),
345
+ transcriptionBackend: config.transcriptionConfig.backend,
346
+ escalationMode: config.escalationConfig.mode,
347
+ },
348
+ }));
349
+ return;
350
+ }
351
+
312
352
  // ── /user/command ──
313
353
  if (req.method === "POST" && url.pathname === "/user/command") {
314
354
  const body = await readBody(req, 4096);
@@ -504,6 +544,7 @@ export function createAppServer(deps: ServerDeps) {
504
544
  });
505
545
  },
506
546
  async destroy(): Promise<void> {
547
+ clearInterval(visionCostCleanup);
507
548
  wsHandler.destroy();
508
549
  wss.close();
509
550
  await new Promise<void>((resolve) => httpServer.close(() => resolve()));
@@ -78,7 +78,36 @@ export interface SpawnCommandMessage {
78
78
  text: string;
79
79
  }
80
80
 
81
- export type OutboundMessage = FeedMessage | StatusMessage | PingMessage | SpawnTaskMessage;
81
+ /** Cost update broadcast to overlay. */
82
+ export interface CostMessage {
83
+ type: "cost";
84
+ totalCost: number;
85
+ costBySource: Record<string, number>;
86
+ callCount: number;
87
+ startedAt: number;
88
+ displayEnabled: boolean;
89
+ }
90
+
91
+ /** Entry recorded by CostTracker for each LLM call. */
92
+ export interface CostEntry {
93
+ source: "analyzer" | "transcription" | "vision";
94
+ model: string;
95
+ cost: number;
96
+ tokensIn: number;
97
+ tokensOut: number;
98
+ ts: number;
99
+ }
100
+
101
+ /** Snapshot of accumulated cost state. */
102
+ export interface CostSnapshot {
103
+ totalCost: number;
104
+ costBySource: Record<string, number>;
105
+ costByModel: Record<string, number>;
106
+ callCount: number;
107
+ startedAt: number;
108
+ }
109
+
110
+ export type OutboundMessage = FeedMessage | StatusMessage | PingMessage | SpawnTaskMessage | CostMessage;
82
111
  export type InboundMessage = UserMessage | CommandMessage | PongMessage | ProfilingMessage | UserCommandMessage | SpawnCommandMessage;
83
112
 
84
113
  /** Abstraction for user commands (text now, voice later). */
@@ -253,6 +282,8 @@ export interface AgentResult {
253
282
  voice?: string;
254
283
  voice_stat?: number;
255
284
  voice_confidence?: number;
285
+ /** Actual USD cost returned by OpenRouter (undefined if not available). */
286
+ cost?: number;
256
287
  }
257
288
 
258
289
  export interface AgentEntry extends AgentResult {
@@ -442,6 +473,7 @@ export interface CoreConfig {
442
473
  openclawConfig: OpenClawConfig;
443
474
  situationMdPath: string;
444
475
  traceEnabled: boolean;
476
+ costDisplayEnabled: boolean;
445
477
  traceDir: string;
446
478
  learningConfig: LearningConfig;
447
479
  traitConfig: TraitConfig;