@geravant/sinain 1.7.2 → 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.
@@ -72,10 +72,15 @@ invoke_agent() {
72
72
  ;;
73
73
  codex)
74
74
  codex exec -s danger-full-access \
75
+ --dangerously-bypass-approvals-and-sandbox \
76
+ --skip-git-repo-check \
75
77
  "$prompt"
76
78
  ;;
77
79
  junie)
78
80
  if $JUNIE_HAS_MCP; then
81
+ if [ ! -f "$HOME/.junie/allowlist.json" ]; then
82
+ echo " ⚠ Junie: no allowlist.json — MCP tools may prompt. Run junie --brave once to create it." >&2
83
+ fi
79
84
  junie --output-format text \
80
85
  --mcp-location "$JUNIE_MCP_DIR" \
81
86
  --task "$prompt"
@@ -84,7 +89,7 @@ invoke_agent() {
84
89
  fi
85
90
  ;;
86
91
  goose)
87
- goose run --text "$prompt" \
92
+ GOOSE_MODE=auto goose run --text "$prompt" \
88
93
  --output-format text \
89
94
  --max-turns 10
90
95
  ;;
@@ -342,6 +342,7 @@ async function callModel(
342
342
  try {
343
343
  const jsonStr = raw.replace(/^```\w*\s*\n?/, "").replace(/\n?\s*```\s*$/, "").trim();
344
344
  const parsed = JSON.parse(jsonStr);
345
+ const apiCost = typeof data.usage?.cost === "number" ? data.usage.cost : undefined;
345
346
  return {
346
347
  hud: parsed.hud || "\u2014",
347
348
  digest: parsed.digest || "\u2014",
@@ -352,10 +353,12 @@ async function callModel(
352
353
  tokensOut: data.usage?.completion_tokens || 0,
353
354
  model,
354
355
  parsedOk: true,
356
+ cost: apiCost,
355
357
  };
356
358
  } catch {
357
359
  // Second chance: extract embedded JSON object
358
360
  const match = raw.match(/\{[\s\S]*\}/);
361
+ const apiCost = typeof data.usage?.cost === "number" ? data.usage.cost : undefined;
359
362
  if (match) {
360
363
  try {
361
364
  const parsed = JSON.parse(match[0]);
@@ -370,6 +373,7 @@ async function callModel(
370
373
  tokensOut: data.usage?.completion_tokens || 0,
371
374
  model,
372
375
  parsedOk: true,
376
+ cost: apiCost,
373
377
  };
374
378
  }
375
379
  } catch { /* fall through */ }
@@ -385,6 +389,7 @@ async function callModel(
385
389
  tokensOut: data.usage?.completion_tokens || 0,
386
390
  model,
387
391
  parsedOk: false,
392
+ cost: apiCost,
388
393
  };
389
394
  }
390
395
  } finally {
@@ -4,6 +4,7 @@ import type { FeedBuffer } from "../buffers/feed-buffer.js";
4
4
  import type { SenseBuffer } from "../buffers/sense-buffer.js";
5
5
  import type { AgentConfig, AgentEntry, ContextWindow, EscalationMode, ContextRichness, RecorderStatus, SenseEvent, FeedbackRecord } from "../types.js";
6
6
  import type { Profiler } from "../profiler.js";
7
+ import type { CostTracker } from "../cost/tracker.js";
7
8
  import { buildContextWindow, RICHNESS_PRESETS } from "./context-window.js";
8
9
  import { analyzeContext } from "./analyzer.js";
9
10
  import { writeSituationMd } from "./situation-writer.js";
@@ -40,6 +41,8 @@ export interface AgentLoopDeps {
40
41
  getKnowledgeDocPath?: () => string | null;
41
42
  /** Optional: feedback store for startup recap context. */
42
43
  feedbackStore?: { queryRecent(n: number): FeedbackRecord[] };
44
+ /** Optional: cost tracker for LLM cost accumulation. */
45
+ costTracker?: CostTracker;
43
46
  }
44
47
 
45
48
  export interface TraceContext {
@@ -317,6 +320,17 @@ export class AgentLoop extends EventEmitter {
317
320
  this.deps.profiler?.gauge("agent.parseSuccesses", this.stats.parseSuccesses);
318
321
  this.deps.profiler?.gauge("agent.parseFailures", this.stats.parseFailures);
319
322
 
323
+ if (typeof result.cost === "number" && result.cost > 0) {
324
+ this.deps.costTracker?.record({
325
+ source: "analyzer",
326
+ model: usedModel,
327
+ cost: result.cost,
328
+ tokensIn,
329
+ tokensOut,
330
+ ts: Date.now(),
331
+ });
332
+ }
333
+
320
334
  // Build entry
321
335
  const entry: AgentEntry = {
322
336
  ...result,
@@ -375,12 +389,13 @@ export class AgentLoop extends EventEmitter {
375
389
 
376
390
  // Finish trace
377
391
  const costPerToken = { in: 0.075 / 1_000_000, out: 0.3 / 1_000_000 };
392
+ const estimatedCost = tokensIn * costPerToken.in + tokensOut * costPerToken.out;
378
393
  traceCtx?.finish({
379
394
  totalLatencyMs: Date.now() - entry.ts + latencyMs,
380
395
  llmLatencyMs: latencyMs,
381
396
  llmInputTokens: tokensIn,
382
397
  llmOutputTokens: tokensOut,
383
- llmCost: tokensIn * costPerToken.in + tokensOut * costPerToken.out,
398
+ llmCost: result.cost ?? estimatedCost,
384
399
  escalated: false, // Updated by escalator
385
400
  escalationScore: 0,
386
401
  contextScreenEvents: contextWindow.screenCount,
@@ -477,6 +492,16 @@ export class AgentLoop extends EventEmitter {
477
492
  };
478
493
 
479
494
  const result = await analyzeContext(recapWindow, this.deps.agentConfig, null);
495
+ if (typeof result.cost === "number" && result.cost > 0) {
496
+ this.deps.costTracker?.record({
497
+ source: "analyzer",
498
+ model: result.model,
499
+ cost: result.cost,
500
+ tokensIn: result.tokensIn,
501
+ tokensOut: result.tokensOut,
502
+ ts: Date.now(),
503
+ });
504
+ }
480
505
  if (result?.hud && result.hud !== "—" && result.hud !== "Idle") {
481
506
  this.deps.onHudUpdate(result.hud);
482
507
  log(TAG, `recap tick (${Date.now() - startTs}ms, ${result.tokensIn}in+${result.tokensOut}out tok) hud="${result.hud}"`);
@@ -1,6 +1,7 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import type { TranscriptionConfig, AudioChunk, TranscriptResult } from "../types.js";
3
3
  import type { Profiler } from "../profiler.js";
4
+ import type { CostTracker } from "../cost/tracker.js";
4
5
  import { LocalTranscriptionBackend } from "./transcription-local.js";
5
6
  import { log, warn, error, debug } from "../log.js";
6
7
 
@@ -41,7 +42,10 @@ export class TranscriptionService extends EventEmitter {
41
42
  private dropCount: number = 0;
42
43
  private totalCalls: number = 0;
43
44
 
45
+ private costTracker: CostTracker | null = null;
46
+
44
47
  setProfiler(p: Profiler): void { this.profiler = p; }
48
+ setCostTracker(ct: CostTracker): void { this.costTracker = ct; }
45
49
 
46
50
  constructor(config: TranscriptionConfig) {
47
51
  super();
@@ -219,7 +223,7 @@ export class TranscriptionService extends EventEmitter {
219
223
 
220
224
  const data = await response.json() as {
221
225
  choices?: Array<{ message?: { content?: string } }>;
222
- usage?: { prompt_tokens?: number; completion_tokens?: number };
226
+ usage?: { prompt_tokens?: number; completion_tokens?: number; cost?: number };
223
227
  };
224
228
 
225
229
  const text = data.choices?.[0]?.message?.content?.trim();
@@ -231,6 +235,21 @@ export class TranscriptionService extends EventEmitter {
231
235
  this.profiler?.timerRecord("transcription.call", elapsed);
232
236
  this.totalAudioDurationMs += chunk.durationMs;
233
237
 
238
+ // Track tokens and cost before any early returns — the API call is already billed
239
+ if (data.usage) {
240
+ this.totalTokensConsumed += (data.usage.prompt_tokens || 0) + (data.usage.completion_tokens || 0);
241
+ }
242
+ if (typeof data.usage?.cost === "number" && data.usage.cost > 0) {
243
+ this.costTracker?.record({
244
+ source: "transcription",
245
+ model: this.config.geminiModel,
246
+ cost: data.usage.cost,
247
+ tokensIn: data.usage?.prompt_tokens || 0,
248
+ tokensOut: data.usage?.completion_tokens || 0,
249
+ ts: Date.now(),
250
+ });
251
+ }
252
+
234
253
  if (!text) {
235
254
  warn(TAG, `OpenRouter returned empty transcript (${elapsed}ms)`);
236
255
  return;
@@ -248,10 +267,6 @@ export class TranscriptionService extends EventEmitter {
248
267
 
249
268
  log(TAG, `transcript (${elapsed}ms): "${text.slice(0, 100)}${text.length > 100 ? "..." : ""}"`);
250
269
 
251
- if (data.usage) {
252
- this.totalTokensConsumed += (data.usage.prompt_tokens || 0) + (data.usage.completion_tokens || 0);
253
- }
254
-
255
270
  const result: TranscriptResult = {
256
271
  text,
257
272
  source: "openrouter",
@@ -252,6 +252,7 @@ export function loadConfig(): CoreConfig {
252
252
  situationMdPath,
253
253
  traceEnabled: boolEnv("TRACE_ENABLED", true),
254
254
  traceDir: resolvePath(env("TRACE_DIR", resolve(sinainDataDir(), "traces"))),
255
+ costDisplayEnabled: boolEnv("COST_DISPLAY_ENABLED", false),
255
256
  learningConfig,
256
257
  traitConfig,
257
258
  privacyConfig,
@@ -0,0 +1,64 @@
1
+ import type { CostEntry, CostSnapshot } from "../types.js";
2
+ import { log } from "../log.js";
3
+
4
+ const TAG = "cost";
5
+
6
+ export class CostTracker {
7
+ private totalCost = 0;
8
+ private costBySource = new Map<string, number>();
9
+ private costByModel = new Map<string, number>();
10
+ private callCount = 0;
11
+ private startedAt = Date.now();
12
+ private timer: ReturnType<typeof setInterval> | null = null;
13
+ private onCostUpdate: (snapshot: CostSnapshot) => void;
14
+
15
+ constructor(onCostUpdate: (snapshot: CostSnapshot) => void) {
16
+ this.onCostUpdate = onCostUpdate;
17
+ }
18
+
19
+ record(entry: CostEntry): void {
20
+ if (entry.cost <= 0) return;
21
+ this.totalCost += entry.cost;
22
+ this.callCount++;
23
+ this.costBySource.set(
24
+ entry.source,
25
+ (this.costBySource.get(entry.source) || 0) + entry.cost,
26
+ );
27
+ this.costByModel.set(
28
+ entry.model,
29
+ (this.costByModel.get(entry.model) || 0) + entry.cost,
30
+ );
31
+ this.onCostUpdate(this.getSnapshot());
32
+ }
33
+
34
+ getSnapshot(): CostSnapshot {
35
+ return {
36
+ totalCost: this.totalCost,
37
+ costBySource: Object.fromEntries(this.costBySource),
38
+ costByModel: Object.fromEntries(this.costByModel),
39
+ callCount: this.callCount,
40
+ startedAt: this.startedAt,
41
+ };
42
+ }
43
+
44
+ startPeriodicLog(intervalMs: number): void {
45
+ this.timer = setInterval(() => {
46
+ if (this.callCount === 0) return;
47
+ const elapsed = ((Date.now() - this.startedAt) / 60_000).toFixed(1);
48
+ const sources = [...this.costBySource.entries()]
49
+ .map(([k, v]) => `${k}=$${v.toFixed(6)}`)
50
+ .join(" ");
51
+ const models = [...this.costByModel.entries()]
52
+ .map(([k, v]) => `${k}=$${v.toFixed(6)}`)
53
+ .join(" ");
54
+ log(TAG, `$${this.totalCost.toFixed(6)} total (${this.callCount} calls, ${elapsed} min) | ${sources} | ${models}`);
55
+ }, intervalMs);
56
+ }
57
+
58
+ stop(): void {
59
+ if (this.timer) {
60
+ clearInterval(this.timer);
61
+ this.timer = null;
62
+ }
63
+ }
64
+ }
@@ -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";
@@ -58,6 +59,10 @@ async function main() {
58
59
  // ── Initialize overlay WS handler ──
59
60
  const wsHandler = new WsHandler();
60
61
 
62
+ // ── Initialize cost tracker ──
63
+ const costTracker = new CostTracker((snapshot) => wsHandler.broadcastCost(snapshot, config.costDisplayEnabled));
64
+ costTracker.startPeriodicLog(60_000);
65
+
61
66
  // ── Initialize tracing ──
62
67
  const tracer = config.traceEnabled ? new Tracer() : null;
63
68
  const traceStore = config.traceEnabled ? new TraceStore(config.traceDir) : null;
@@ -170,6 +175,7 @@ async function main() {
170
175
  return null;
171
176
  },
172
177
  feedbackStore: feedbackStore ?? undefined,
178
+ costTracker,
173
179
  });
174
180
 
175
181
  // ── Wire learning signal collector (needs agentLoop) ──
@@ -197,6 +203,7 @@ async function main() {
197
203
  systemAudioPipeline.setProfiler(profiler);
198
204
  if (micPipeline) micPipeline.setProfiler(profiler);
199
205
  transcription.setProfiler(profiler);
206
+ transcription.setCostTracker(costTracker);
200
207
 
201
208
  // Wire: audio chunks → transcription (both pipelines share the same transcription service)
202
209
  systemAudioPipeline.on("chunk", (chunk) => {
@@ -308,6 +315,7 @@ async function main() {
308
315
  senseBuffer,
309
316
  wsHandler,
310
317
  profiler,
318
+ costTracker,
311
319
  feedbackStore: feedbackStore ?? undefined,
312
320
  isScreenActive: () => screenActive,
313
321
 
@@ -537,12 +545,14 @@ async function main() {
537
545
  log(TAG, ` agent: ${config.agentConfig.enabled ? "enabled" : "disabled"}`);
538
546
  log(TAG, ` escal: ${config.escalationConfig.mode}`);
539
547
  log(TAG, ` traits: ${config.traitConfig.enabled ? "enabled" : "disabled"} (${traitRoster.length} traits)`);
548
+ log(TAG, ` cost: display=${config.costDisplayEnabled ? "on" : "off"} (always logged)`);
540
549
 
541
550
  // ── Graceful shutdown ──
542
551
  const shutdown = async (signal: string) => {
543
552
  log(TAG, `${signal} received, shutting down...`);
544
553
  clearInterval(bufferGaugeTimer);
545
554
  if (feedbackSummaryTimer) clearInterval(feedbackSummaryTimer);
555
+ costTracker.stop();
546
556
  profiler.stop();
547
557
  recorder.forceStop(); // Stop any active recording
548
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";
@@ -142,6 +144,16 @@ function handleCommand(action: string, deps: CommandDeps): void {
142
144
  log(TAG, `traits toggled ${nowEnabled ? "ON" : "OFF"}`);
143
145
  break;
144
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
+ }
145
157
  default:
146
158
  log(TAG, `unhandled command: ${action}`);
147
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);
@@ -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;