@geravant/sinain 1.8.0 → 1.10.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/config.js ADDED
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * sinain config — interactive section-based config editor.
4
+ * Edit individual settings without re-running the full onboard wizard.
5
+ */
6
+ import * as p from "@clack/prompts";
7
+ import {
8
+ c, guard, readEnv, writeEnv, summarizeConfig, runHealthCheck,
9
+ stepApiKey, stepTranscription, stepGateway, stepPrivacy, stepModel, stepAgent,
10
+ ENV_PATH, IS_WINDOWS, HOME, PKG_DIR,
11
+ } from "./config-shared.js";
12
+ import fs from "fs";
13
+ import path from "path";
14
+
15
+ // ── Section definitions ────────────────────────────────────────────────────
16
+
17
+ const SECTIONS = [
18
+ { value: "apikey", label: "API Key", hint: "OpenRouter API key" },
19
+ { value: "transcription", label: "Transcription", hint: "Cloud or local whisper" },
20
+ { value: "model", label: "Model", hint: "AI model for analysis" },
21
+ { value: "privacy", label: "Privacy", hint: "Standard / strict / paranoid" },
22
+ { value: "gateway", label: "Gateway", hint: "OpenClaw connection" },
23
+ { value: "agent", label: "Agent", hint: "Claude / Codex / Goose / ..." },
24
+ { value: "health", label: "Health check", hint: "Test core + gateway status" },
25
+ ];
26
+
27
+ // ── Section handlers ───────────────────────────────────────────────────────
28
+
29
+ async function runSection(section, existing) {
30
+ switch (section) {
31
+ case "apikey": {
32
+ const key = await stepApiKey(existing);
33
+ return { OPENROUTER_API_KEY: key };
34
+ }
35
+ case "transcription": {
36
+ const backend = await stepTranscription(existing);
37
+ const vars = { TRANSCRIPTION_BACKEND: backend };
38
+ if (backend === "local") {
39
+ const modelDir = path.join(HOME, "models");
40
+ const modelPath = path.join(modelDir, "ggml-large-v3-turbo.bin");
41
+ if (fs.existsSync(modelPath)) {
42
+ vars.LOCAL_WHISPER_MODEL = modelPath;
43
+ }
44
+ }
45
+ return vars;
46
+ }
47
+ case "model": {
48
+ const model = await stepModel(existing);
49
+ return { AGENT_MODEL: model };
50
+ }
51
+ case "privacy": {
52
+ const mode = await stepPrivacy(existing);
53
+ return { PRIVACY_MODE: mode };
54
+ }
55
+ case "gateway": {
56
+ return await stepGateway(existing);
57
+ }
58
+ case "agent": {
59
+ const agent = await stepAgent(existing);
60
+ return { SINAIN_AGENT: agent };
61
+ }
62
+ case "health": {
63
+ await runHealthCheck();
64
+ return null; // no vars to write
65
+ }
66
+ }
67
+ }
68
+
69
+ // ── List command ────────────────────────────────────────────────────────────
70
+
71
+ function printConfigList(existing) {
72
+ const lines = summarizeConfig(existing);
73
+ if (lines.length === 0) {
74
+ console.log(c.dim(" No config found. Run: sinain onboard"));
75
+ } else {
76
+ console.log();
77
+ console.log(c.bold(" Current config") + c.dim(` (${ENV_PATH})`));
78
+ console.log();
79
+ for (const line of lines) console.log(` ${line}`);
80
+ console.log();
81
+ }
82
+ }
83
+
84
+ // ── Main ───────────────────────────────────────────────────────────────────
85
+
86
+ async function runConfigWizard(sectionsFilter) {
87
+ p.intro("sinain config");
88
+
89
+ const existing = readEnv();
90
+ if (Object.keys(existing).length === 0) {
91
+ p.log.warn("No config found. Run sinain onboard first.");
92
+ p.outro("sinain onboard");
93
+ return;
94
+ }
95
+
96
+ p.note(summarizeConfig(existing).join("\n"), "Current config");
97
+
98
+ // If specific sections requested via --sections, run just those
99
+ if (sectionsFilter && sectionsFilter.length > 0) {
100
+ for (const section of sectionsFilter) {
101
+ const vars = await runSection(section, existing);
102
+ if (vars) {
103
+ Object.assign(existing, vars);
104
+ writeEnv(existing);
105
+ p.log.success(`${section} updated.`);
106
+ }
107
+ }
108
+ p.outro("Done.");
109
+ return;
110
+ }
111
+
112
+ // Interactive loop
113
+ while (true) {
114
+ const choice = guard(await p.select({
115
+ message: "What to configure?",
116
+ options: [
117
+ ...SECTIONS,
118
+ { value: "__done", label: "Done", hint: "Save and exit" },
119
+ ],
120
+ }));
121
+
122
+ if (choice === "__done") break;
123
+
124
+ const vars = await runSection(choice, existing);
125
+ if (vars) {
126
+ Object.assign(existing, vars);
127
+ writeEnv(existing);
128
+ p.log.success(`${choice} updated.`);
129
+ }
130
+ }
131
+
132
+ p.outro("Config saved.");
133
+ }
134
+
135
+ // ── CLI entry point ────────────────────────────────────────────────────────
136
+
137
+ const args = process.argv.slice(3); // skip node, cli.js, "config"
138
+
139
+ if (args[0] === "list" || args[0] === "ls") {
140
+ printConfigList(readEnv());
141
+ } else {
142
+ let sectionsFilter = null;
143
+ for (const arg of args) {
144
+ if (arg.startsWith("--sections=")) {
145
+ sectionsFilter = arg.slice(11).split(",").filter(Boolean);
146
+ }
147
+ }
148
+ runConfigWizard(sectionsFilter).catch((err) => {
149
+ console.error(c.red(` Error: ${err.message}`));
150
+ process.exit(1);
151
+ });
152
+ }
package/index.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * - Strips <private> tags from tool results before persistence
9
9
  */
10
10
 
11
- import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, chmodSync, copyFileSync } from "node:fs";
11
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, copyFileSync } from "node:fs";
12
12
  import { join, dirname } from "node:path";
13
13
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
14
14
 
@@ -358,8 +358,6 @@ export default function sinainHudPlugin(api: OpenClawPluginApi): void {
358
358
  const memorySource = cfg.memoryPath ? api.resolvePath(cfg.memoryPath) : undefined;
359
359
  if (memorySource) {
360
360
  store.deployDir(memorySource, "sinain-memory");
361
- const gbPath = join(workspaceDir, "sinain-memory", "git_backup.sh");
362
- if (existsSync(gbPath)) try { chmodSync(gbPath, 0o755); } catch {}
363
361
  }
364
362
 
365
363
  const modulesSource = cfg.modulesPath ? api.resolvePath(cfg.modulesPath) : undefined;
package/launcher.js CHANGED
@@ -685,7 +685,13 @@ function loadUserEnv() {
685
685
  const eq = trimmed.indexOf("=");
686
686
  if (eq === -1) continue;
687
687
  const key = trimmed.slice(0, eq).trim();
688
- const val = trimmed.slice(eq + 1).trim().replace(/^["']|["']$/g, "");
688
+ let val = trimmed.slice(eq + 1).trim();
689
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
690
+ val = val.slice(1, -1);
691
+ } else {
692
+ const ci = val.search(/\s+#/);
693
+ if (ci !== -1) val = val.slice(0, ci).trimEnd();
694
+ }
689
695
  // Don't override existing env vars
690
696
  if (!process.env[key]) {
691
697
  process.env[key] = val;
package/onboard.js ADDED
@@ -0,0 +1,345 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * sinain onboard — interactive setup wizard using @clack/prompts
4
+ * Modeled after `openclaw onboard` for a familiar, polished experience.
5
+ */
6
+ import * as p from "@clack/prompts";
7
+ import fs from "fs";
8
+ import path from "path";
9
+ import { execFileSync } from "child_process";
10
+ import {
11
+ c, guard, maskKey, readEnv, writeEnv, summarizeConfig, runHealthCheck,
12
+ stepApiKey, stepTranscription, stepGateway, stepPrivacy, stepModel,
13
+ HOME, SINAIN_DIR, ENV_PATH, PKG_DIR, IS_WINDOWS, IS_MAC,
14
+ } from "./config-shared.js";
15
+
16
+ // ── Header ──────────────────────────────────────────────────────────────────
17
+
18
+ function printHeader() {
19
+ console.log();
20
+ console.log(c.bold(" ┌─────────────────────────────────────────┐"));
21
+ console.log(c.bold(" │ │"));
22
+ console.log(c.bold(" │") + c.cyan(" ╔═╗╦╔╗╔╔═╗╦╔╗╔ ╦ ╦╦ ╦╔╦╗ ") + c.bold("│"));
23
+ console.log(c.bold(" │") + c.cyan(" ╚═╗║║║║╠═╣║║║║ ╠═╣║ ║ ║║ ") + c.bold("│"));
24
+ console.log(c.bold(" │") + c.cyan(" ╚═╝╩╝╚╝╩ ╩╩╝╚╝ ╩ ╩╚═╝═╩╝ ") + c.bold("│"));
25
+ console.log(c.bold(" │") + c.dim(" Privacy-first AI overlay ") + c.bold("│"));
26
+ console.log(c.bold(" │ │"));
27
+ console.log(c.bold(" └─────────────────────────────────────────┘"));
28
+ console.log();
29
+ }
30
+
31
+ // ── Steps (imported from config-shared.js) ──────────────────────────────────
32
+ // stepApiKey, stepTranscription, stepGateway, stepPrivacy, stepModel
33
+ // are imported above and accept an optional label parameter.
34
+
35
+ async function stepOverlay(existing) {
36
+ // Check if overlay is already installed
37
+ const overlayPaths = [
38
+ path.join(SINAIN_DIR, "overlay", "SinainHUD.app"),
39
+ path.join(SINAIN_DIR, "overlay", "sinain_hud.exe"),
40
+ ];
41
+ const overlayInstalled = overlayPaths.some((p) => fs.existsSync(p));
42
+
43
+ const choice = guard(await p.select({
44
+ message: "Install overlay",
45
+ options: [
46
+ {
47
+ value: "download",
48
+ label: "Download pre-built app (recommended)",
49
+ hint: "No Flutter SDK needed",
50
+ },
51
+ {
52
+ value: "source",
53
+ label: "Build from source",
54
+ hint: "Requires Flutter SDK",
55
+ },
56
+ {
57
+ value: "skip",
58
+ label: overlayInstalled ? "Skip (already installed)" : "Skip for now",
59
+ hint: overlayInstalled ? "SinainHUD.app detected" : "Install later: sinain setup-overlay",
60
+ },
61
+ ],
62
+ initialValue: overlayInstalled ? "skip" : "download",
63
+ }));
64
+
65
+ if (choice === "download" || choice === "source") {
66
+ const s = p.spinner();
67
+ const label = choice === "download" ? "Downloading overlay..." : "Building overlay from source...";
68
+ s.start(label);
69
+ try {
70
+ // setup-overlay.js handles both modes via process.argv
71
+ if (choice === "source") process.argv.push("--from-source");
72
+ await import("./setup-overlay.js");
73
+ s.stop(c.green("Overlay installed."));
74
+ } catch (err) {
75
+ s.stop(c.yellow(`Failed: ${err.message}`));
76
+ p.note("Install manually: sinain setup-overlay", "Overlay");
77
+ }
78
+ }
79
+ }
80
+
81
+ // ── Main ────────────────────────────────────────────────────────────────────
82
+
83
+ export async function runOnboard(args = {}) {
84
+ printHeader();
85
+ p.intro("SinainHUD setup");
86
+
87
+ const existing = readEnv(ENV_PATH);
88
+ const hasExisting = Object.keys(existing).length > 0;
89
+
90
+ // ── Existing config handling ────────────────────────────────────────────
91
+
92
+ let configAction = "fresh";
93
+ if (hasExisting) {
94
+ p.note(summarizeConfig(existing).join("\n"), "Existing config detected");
95
+
96
+ configAction = guard(await p.select({
97
+ message: "Config handling",
98
+ options: [
99
+ { value: "keep", label: "Use existing values" },
100
+ { value: "update", label: "Update values" },
101
+ { value: "reset", label: "Reset (start fresh)" },
102
+ ],
103
+ initialValue: "keep",
104
+ }));
105
+
106
+ if (configAction === "keep") {
107
+ p.log.success("Using existing configuration.");
108
+ await stepOverlay(existing);
109
+ await runHealthCheck();
110
+ printOutro();
111
+ return;
112
+ }
113
+
114
+ if (configAction === "reset") {
115
+ fs.unlinkSync(ENV_PATH);
116
+ p.log.info("Config reset.");
117
+ }
118
+ }
119
+
120
+ const base = configAction === "update" ? existing : {};
121
+
122
+ // ── Flow selection ──────────────────────────────────────────────────────
123
+
124
+ const flow = args.flow || guard(await p.select({
125
+ message: "Setup mode",
126
+ options: [
127
+ {
128
+ value: "quickstart",
129
+ label: "QuickStart",
130
+ hint: "Get running in 2 minutes. Configure details later.",
131
+ },
132
+ {
133
+ value: "advanced",
134
+ label: "Advanced",
135
+ hint: "Full control over privacy, models, and connections.",
136
+ },
137
+ ],
138
+ initialValue: "quickstart",
139
+ }));
140
+
141
+ const totalSteps = flow === "quickstart" ? 2 : 5;
142
+
143
+ // ── Collect vars ────────────────────────────────────────────────────────
144
+
145
+ const vars = { ...base };
146
+
147
+ // Step 1: API key (both flows)
148
+ const apiKey = await stepApiKey(base, `[1/${totalSteps}] OpenRouter API key`);
149
+ vars.OPENROUTER_API_KEY = apiKey;
150
+ p.log.success("API key saved.");
151
+
152
+ if (flow === "quickstart") {
153
+ // QuickStart: sensible defaults
154
+ vars.TRANSCRIPTION_BACKEND = base.TRANSCRIPTION_BACKEND || "openrouter";
155
+ vars.PRIVACY_MODE = base.PRIVACY_MODE || "standard";
156
+ vars.AGENT_MODEL = base.AGENT_MODEL || "google/gemini-2.5-flash-lite";
157
+ vars.ESCALATION_MODE = base.ESCALATION_MODE || "off";
158
+ vars.SINAIN_AGENT = base.SINAIN_AGENT || "claude";
159
+ if (!vars.OPENCLAW_WS_URL) {
160
+ vars.OPENCLAW_WS_URL = "";
161
+ vars.OPENCLAW_HTTP_URL = "";
162
+ }
163
+
164
+ p.note(
165
+ [
166
+ `Transcription: ${vars.TRANSCRIPTION_BACKEND}`,
167
+ `Privacy: ${vars.PRIVACY_MODE}`,
168
+ `Model: ${vars.AGENT_MODEL}`,
169
+ `Escalation: ${vars.ESCALATION_MODE}`,
170
+ "",
171
+ `Change later: sinain config`,
172
+ ].join("\n"),
173
+ "QuickStart defaults",
174
+ );
175
+ } else {
176
+ // Advanced flow: steps 2-5
177
+ const transcription = await stepTranscription(base, "[2/5] Audio transcription");
178
+ vars.TRANSCRIPTION_BACKEND = transcription;
179
+ p.log.success(`Using ${transcription === "openrouter" ? "cloud" : "local"} transcription.`);
180
+
181
+ if (transcription === "local") {
182
+ const modelDir = path.join(HOME, "models");
183
+ const modelPath = path.join(modelDir, "ggml-large-v3-turbo.bin");
184
+ if (!fs.existsSync(modelPath)) {
185
+ const download = guard(await p.confirm({
186
+ message: "Download Whisper model (~1.5 GB)?",
187
+ initialValue: true,
188
+ }));
189
+ if (download) {
190
+ const s = p.spinner();
191
+ s.start("Downloading Whisper model...");
192
+ try {
193
+ fs.mkdirSync(modelDir, { recursive: true });
194
+ execFileSync("curl", [
195
+ "-L", "--progress-bar",
196
+ "-o", modelPath,
197
+ "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin",
198
+ ], { stdio: "inherit" });
199
+ s.stop(c.green("Model downloaded."));
200
+ vars.LOCAL_WHISPER_MODEL = modelPath;
201
+ } catch {
202
+ s.stop(c.yellow("Download failed. You can download manually later."));
203
+ }
204
+ }
205
+ } else {
206
+ vars.LOCAL_WHISPER_MODEL = modelPath;
207
+ p.log.info(`Whisper model found: ${c.dim(modelPath)}`);
208
+ }
209
+ }
210
+
211
+ const gatewayVars = await stepGateway(base, "[3/5] OpenClaw gateway");
212
+ Object.assign(vars, gatewayVars);
213
+ if (gatewayVars.ESCALATION_MODE === "off") {
214
+ p.log.info("Standalone mode (no gateway).");
215
+ } else {
216
+ p.log.success("Gateway configured.");
217
+ }
218
+
219
+ const privacy = await stepPrivacy(base, "[4/5] Privacy mode");
220
+ vars.PRIVACY_MODE = privacy;
221
+ p.log.success(`Privacy: ${privacy}.`);
222
+
223
+ const model = await stepModel(base, "[5/5] AI model for HUD analysis");
224
+ vars.AGENT_MODEL = model;
225
+ p.log.success(`Model: ${model}.`);
226
+
227
+ vars.SINAIN_AGENT = base.SINAIN_AGENT || "claude";
228
+ }
229
+
230
+ // ── Common defaults ───────────────────────────────────────────────────
231
+
232
+ vars.SINAIN_CORE_URL = vars.SINAIN_CORE_URL || "http://localhost:9500";
233
+ vars.SINAIN_POLL_INTERVAL = vars.SINAIN_POLL_INTERVAL || "5";
234
+ vars.SINAIN_HEARTBEAT_INTERVAL = vars.SINAIN_HEARTBEAT_INTERVAL || "900";
235
+ vars.AUDIO_CAPTURE_CMD = vars.AUDIO_CAPTURE_CMD || "screencapturekit";
236
+ vars.AUDIO_AUTO_START = vars.AUDIO_AUTO_START || "true";
237
+ vars.PORT = vars.PORT || "9500";
238
+
239
+ // ── Write config ──────────────────────────────────────────────────────
240
+
241
+ const s = p.spinner();
242
+ s.start("Writing configuration...");
243
+ writeEnv(vars);
244
+ s.stop(c.green(`Config saved: ${c.dim(ENV_PATH)}`));
245
+
246
+ // ── Overlay ───────────────────────────────────────────────────────────
247
+
248
+ await stepOverlay(base);
249
+
250
+ // ── Health check ──────────────────────────────────────────────────────
251
+
252
+ await runHealthCheck();
253
+
254
+ // ── What now ──────────────────────────────────────────────────────────
255
+
256
+ printOutro();
257
+
258
+ // ── Start? ────────────────────────────────────────────────────────────
259
+
260
+ const startNow = guard(await p.confirm({
261
+ message: "Start sinain now?",
262
+ initialValue: true,
263
+ }));
264
+
265
+ if (startNow) {
266
+ p.outro("Launching sinain...");
267
+ try {
268
+ await import("./launcher.js");
269
+ } catch (err) {
270
+ console.log(c.yellow(` Launch failed: ${err.message}`));
271
+ console.log(c.dim(" Try manually: sinain start"));
272
+ }
273
+ } else {
274
+ p.outro("Run when ready: sinain start");
275
+ }
276
+ }
277
+
278
+ function printOutro() {
279
+ const hotkey = IS_WINDOWS ? "Ctrl+Shift" : "Cmd+Shift";
280
+ p.note(
281
+ [
282
+ "Hotkeys:",
283
+ ` ${hotkey}+Space — Show/hide overlay`,
284
+ ` ${hotkey}+E — Cycle tabs (Stream → Agent → Tasks)`,
285
+ ` ${hotkey}+C — Toggle click-through`,
286
+ ` ${hotkey}+M — Cycle display mode`,
287
+ "",
288
+ "Docs: https://github.com/geravant/sinain-hud",
289
+ "Re-run: sinain onboard (or sinain onboard --advanced)",
290
+ ].join("\n"),
291
+ "What now",
292
+ );
293
+ }
294
+
295
+ // ── CLI entry point ─────────────────────────────────────────────────────────
296
+
297
+ const cliArgs = process.argv.slice(2);
298
+ const flags = {};
299
+ for (const arg of cliArgs) {
300
+ if (arg === "--advanced") flags.flow = "advanced";
301
+ if (arg === "--quickstart") flags.flow = "quickstart";
302
+ if (arg.startsWith("--key=")) flags.key = arg.slice(6);
303
+ if (arg === "--non-interactive") flags.nonInteractive = true;
304
+ if (arg === "--reset") flags.reset = true;
305
+ }
306
+
307
+ if (flags.reset) {
308
+ if (fs.existsSync(ENV_PATH)) {
309
+ fs.unlinkSync(ENV_PATH);
310
+ console.log(c.green(" Config reset."));
311
+ }
312
+ }
313
+
314
+ if (flags.nonInteractive) {
315
+ const vars = {
316
+ OPENROUTER_API_KEY: flags.key || process.env.OPENROUTER_API_KEY || "",
317
+ TRANSCRIPTION_BACKEND: "openrouter",
318
+ PRIVACY_MODE: "standard",
319
+ AGENT_MODEL: "google/gemini-2.5-flash-lite",
320
+ ESCALATION_MODE: "off",
321
+ SINAIN_AGENT: "claude",
322
+ OPENCLAW_WS_URL: "",
323
+ OPENCLAW_HTTP_URL: "",
324
+ PORT: "9500",
325
+ AUDIO_CAPTURE_CMD: "screencapturekit",
326
+ AUDIO_AUTO_START: "true",
327
+ SINAIN_CORE_URL: "http://localhost:9500",
328
+ SINAIN_POLL_INTERVAL: "5",
329
+ SINAIN_HEARTBEAT_INTERVAL: "900",
330
+ };
331
+
332
+ if (!vars.OPENROUTER_API_KEY) {
333
+ console.error(c.red(" --key=<key> or OPENROUTER_API_KEY required for non-interactive mode."));
334
+ process.exit(1);
335
+ }
336
+
337
+ writeEnv(vars);
338
+ console.log(c.green(` Config written to ${ENV_PATH}`));
339
+ process.exit(0);
340
+ } else {
341
+ runOnboard(flags).catch((err) => {
342
+ console.error(c.red(` Error: ${err.message}`));
343
+ process.exit(1);
344
+ });
345
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geravant/sinain",
3
- "version": "1.8.0",
3
+ "version": "1.10.0",
4
4
  "description": "Ambient intelligence that sees what you see, hears what you hear, and acts on your behalf",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,6 +14,9 @@
14
14
  },
15
15
  "files": [
16
16
  "cli.js",
17
+ "config.js",
18
+ "config-shared.js",
19
+ "onboard.js",
17
20
  "launcher.js",
18
21
  "setup-overlay.js",
19
22
  "setup-sck-capture.js",
@@ -47,5 +50,8 @@
47
50
  "./index.ts"
48
51
  ]
49
52
  },
50
- "license": "MIT"
53
+ "license": "MIT",
54
+ "dependencies": {
55
+ "@clack/prompts": "^1.1.0"
56
+ }
51
57
  }
@@ -371,13 +371,17 @@ def main():
371
371
  try:
372
372
  from PIL import Image as PILImage
373
373
  pil = PILImage.fromarray(frame) if isinstance(frame, np.ndarray) else frame
374
- scene = vision_provider.describe(pil, prompt=prompt or None)
375
- if scene:
376
- log(f"vision: {scene[:80]}...")
374
+ result = vision_provider.describe(pil, prompt=prompt or None)
375
+ scene = result.text
376
+ v_cost = result.cost
377
+ if scene or v_cost:
378
+ if scene:
379
+ log(f"vision: {scene[:80]}...")
377
380
  ctx_ev = SenseEvent(type="context", ts=ts)
378
- ctx_ev.observation = SenseObservation(scene=scene)
381
+ ctx_ev.observation = SenseObservation(scene=scene or "")
379
382
  ctx_ev.meta = meta
380
383
  ctx_ev.roi = package_full_frame(frame)
384
+ ctx_ev.vision_cost = v_cost
381
385
  sender.send(ctx_ev)
382
386
  except Exception as e:
383
387
  log(f"vision error: {e}")
@@ -43,6 +43,7 @@ class SenseEvent:
43
43
  diff: dict | None = None
44
44
  meta: SenseMeta = field(default_factory=SenseMeta)
45
45
  observation: SenseObservation = field(default_factory=SenseObservation)
46
+ vision_cost: dict | None = None # {cost, tokens_in, tokens_out, model}
46
47
 
47
48
 
48
49
  class DecisionGate: