@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.
- package/.env.example +3 -0
- package/cli.js +16 -2
- package/config-shared.js +469 -0
- package/config.js +152 -0
- package/launcher.js +7 -1
- package/onboard.js +345 -0
- package/package.json +8 -2
- package/sense_client/__main__.py +8 -4
- package/sense_client/gate.py +1 -0
- package/sense_client/ocr.py +52 -22
- package/sense_client/sender.py +2 -0
- package/sense_client/vision.py +31 -11
- package/sinain-agent/run.sh +6 -1
- package/sinain-core/src/agent/analyzer.ts +5 -0
- package/sinain-core/src/agent/loop.ts +26 -1
- package/sinain-core/src/audio/transcription.ts +20 -5
- package/sinain-core/src/config.ts +1 -0
- package/sinain-core/src/cost/tracker.ts +64 -0
- package/sinain-core/src/index.ts +10 -0
- package/sinain-core/src/overlay/commands.ts +12 -0
- package/sinain-core/src/overlay/ws-handler.ts +27 -0
- package/sinain-core/src/server.ts +41 -0
- package/sinain-core/src/types.ts +33 -1
package/sinain-agent/run.sh
CHANGED
|
@@ -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:
|
|
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
|
+
}
|
package/sinain-core/src/index.ts
CHANGED
|
@@ -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()));
|
package/sinain-core/src/types.ts
CHANGED
|
@@ -78,7 +78,36 @@ export interface SpawnCommandMessage {
|
|
|
78
78
|
text: string;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
|
|
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;
|