@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/.env.example
CHANGED
|
@@ -106,3 +106,6 @@ SITUATION_MD_PATH=~/.openclaw/workspace/SITUATION.md
|
|
|
106
106
|
# ── Tracing ──────────────────────────────────────────────────────────────────
|
|
107
107
|
TRACE_ENABLED=true
|
|
108
108
|
TRACE_DIR=~/.sinain-core/traces
|
|
109
|
+
|
|
110
|
+
# ── Cost Display ─────────────────────────────────────────────────────────────
|
|
111
|
+
COST_DISPLAY_ENABLED=false # show LLM cost counter in overlay (always logged to stdout)
|
package/cli.js
CHANGED
|
@@ -24,8 +24,19 @@ switch (cmd) {
|
|
|
24
24
|
await showStatus();
|
|
25
25
|
break;
|
|
26
26
|
|
|
27
|
+
case "onboard":
|
|
28
|
+
await import("./onboard.js");
|
|
29
|
+
break;
|
|
30
|
+
|
|
31
|
+
case "config":
|
|
32
|
+
await import("./config.js");
|
|
33
|
+
break;
|
|
34
|
+
|
|
27
35
|
case "setup":
|
|
28
|
-
|
|
36
|
+
// Legacy — redirect to onboard
|
|
37
|
+
console.log("\x1b[33m ⚠ `sinain setup` is deprecated. Use: sinain onboard\x1b[0m");
|
|
38
|
+
console.log("\x1b[2m Or: sinain onboard --advanced for full options\x1b[0m\n");
|
|
39
|
+
await import("./onboard.js");
|
|
29
40
|
break;
|
|
30
41
|
|
|
31
42
|
case "setup-overlay":
|
|
@@ -513,10 +524,13 @@ function printUsage() {
|
|
|
513
524
|
sinain — AI overlay system for macOS and Windows
|
|
514
525
|
|
|
515
526
|
Usage:
|
|
527
|
+
sinain onboard Interactive setup wizard (recommended)
|
|
528
|
+
sinain onboard --advanced Full setup with privacy, models, gateway options
|
|
529
|
+
sinain onboard --reset Reset config and start fresh
|
|
516
530
|
sinain start [options] Launch sinain services
|
|
517
531
|
sinain stop Stop all sinain services
|
|
518
532
|
sinain status Check what's running
|
|
519
|
-
sinain setup
|
|
533
|
+
sinain setup (deprecated — use onboard)
|
|
520
534
|
sinain setup-overlay Download pre-built overlay app
|
|
521
535
|
sinain setup-sck-capture Download sck-capture audio binary (macOS)
|
|
522
536
|
sinain export-knowledge Export knowledge for transfer to another machine
|
package/config-shared.js
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers and step functions for sinain onboard + sinain config.
|
|
3
|
+
* Both commands import from here to avoid duplication.
|
|
4
|
+
*/
|
|
5
|
+
import * as p from "@clack/prompts";
|
|
6
|
+
import fs from "fs";
|
|
7
|
+
import path from "path";
|
|
8
|
+
import os from "os";
|
|
9
|
+
import net from "net";
|
|
10
|
+
import { execFileSync } from "child_process";
|
|
11
|
+
|
|
12
|
+
export const HOME = os.homedir();
|
|
13
|
+
export const SINAIN_DIR = path.join(HOME, ".sinain");
|
|
14
|
+
export const ENV_PATH = path.join(SINAIN_DIR, ".env");
|
|
15
|
+
export const PKG_DIR = path.dirname(new URL(import.meta.url).pathname);
|
|
16
|
+
export const IS_WINDOWS = os.platform() === "win32";
|
|
17
|
+
export const IS_MAC = os.platform() === "darwin";
|
|
18
|
+
|
|
19
|
+
// ── Colors ──────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
export const c = {
|
|
22
|
+
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
23
|
+
yellow: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
24
|
+
red: (s) => `\x1b[31m${s}\x1b[0m`,
|
|
25
|
+
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
26
|
+
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
27
|
+
cyan: (s) => `\x1b[36m${s}\x1b[0m`,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
export function guard(value) {
|
|
33
|
+
if (p.isCancel(value)) {
|
|
34
|
+
p.cancel("Cancelled.");
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function cmdExists(cmd) {
|
|
41
|
+
try {
|
|
42
|
+
execFileSync(IS_WINDOWS ? "where" : "which", [cmd], { stdio: "pipe" });
|
|
43
|
+
return true;
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isPortOpen(port) {
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
const sock = new net.Socket();
|
|
52
|
+
sock.setTimeout(800);
|
|
53
|
+
sock.on("connect", () => { sock.destroy(); resolve(true); });
|
|
54
|
+
sock.on("error", () => resolve(false));
|
|
55
|
+
sock.on("timeout", () => { sock.destroy(); resolve(false); });
|
|
56
|
+
sock.connect(port, "127.0.0.1");
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function maskKey(key) {
|
|
61
|
+
if (!key || key.length < 12) return "****";
|
|
62
|
+
return key.slice(0, 8) + "..." + key.slice(-4);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Read existing .env into a key=value map */
|
|
66
|
+
export function readEnv(envPath = ENV_PATH) {
|
|
67
|
+
if (!fs.existsSync(envPath)) return {};
|
|
68
|
+
const content = fs.readFileSync(envPath, "utf-8");
|
|
69
|
+
const vars = {};
|
|
70
|
+
for (const line of content.split("\n")) {
|
|
71
|
+
const trimmed = line.trim();
|
|
72
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
73
|
+
const eq = trimmed.indexOf("=");
|
|
74
|
+
if (eq === -1) continue;
|
|
75
|
+
const key = trimmed.slice(0, eq).trim();
|
|
76
|
+
const val = trimmed.slice(eq + 1).trim();
|
|
77
|
+
if (val) vars[key] = val;
|
|
78
|
+
}
|
|
79
|
+
return vars;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Write vars to .env, preserving template structure */
|
|
83
|
+
export function writeEnv(vars) {
|
|
84
|
+
fs.mkdirSync(SINAIN_DIR, { recursive: true });
|
|
85
|
+
|
|
86
|
+
const examplePath = path.join(PKG_DIR, ".env.example");
|
|
87
|
+
let template = fs.existsSync(examplePath)
|
|
88
|
+
? fs.readFileSync(examplePath, "utf-8")
|
|
89
|
+
: "";
|
|
90
|
+
|
|
91
|
+
if (template) {
|
|
92
|
+
for (const [k, v] of Object.entries(vars)) {
|
|
93
|
+
const regex = new RegExp(`^#?\\s*${k}=.*$`, "m");
|
|
94
|
+
if (regex.test(template)) {
|
|
95
|
+
template = template.replace(regex, `${k}=${v}`);
|
|
96
|
+
} else {
|
|
97
|
+
template += `\n${k}=${v}`;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
template = `# Generated by sinain — ${new Date().toISOString()}\n${template}`;
|
|
101
|
+
fs.writeFileSync(ENV_PATH, template);
|
|
102
|
+
} else {
|
|
103
|
+
const lines = [
|
|
104
|
+
"# sinain configuration",
|
|
105
|
+
`# Generated by sinain — ${new Date().toISOString()}`,
|
|
106
|
+
"",
|
|
107
|
+
];
|
|
108
|
+
for (const [k, v] of Object.entries(vars)) lines.push(`${k}=${v}`);
|
|
109
|
+
lines.push("");
|
|
110
|
+
fs.writeFileSync(ENV_PATH, lines.join("\n"));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function fetchHealth(port = 9500) {
|
|
115
|
+
try {
|
|
116
|
+
const res = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(2000) });
|
|
117
|
+
return await res.json();
|
|
118
|
+
} catch { return null; }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Build a config summary for display */
|
|
122
|
+
export function summarizeConfig(existing) {
|
|
123
|
+
const lines = [];
|
|
124
|
+
if (existing.OPENROUTER_API_KEY) lines.push(`API Key: ${maskKey(existing.OPENROUTER_API_KEY)}`);
|
|
125
|
+
if (existing.TRANSCRIPTION_BACKEND) lines.push(`Transcription: ${existing.TRANSCRIPTION_BACKEND}`);
|
|
126
|
+
if (existing.AGENT_MODEL) lines.push(`Model: ${existing.AGENT_MODEL}`);
|
|
127
|
+
if (existing.PRIVACY_MODE) lines.push(`Privacy: ${existing.PRIVACY_MODE}`);
|
|
128
|
+
if (existing.ESCALATION_MODE) lines.push(`Escalation: ${existing.ESCALATION_MODE}`);
|
|
129
|
+
if (existing.OPENCLAW_WS_URL) lines.push(`Gateway: ${existing.OPENCLAW_WS_URL}`);
|
|
130
|
+
if (existing.SINAIN_AGENT) lines.push(`Agent: ${existing.SINAIN_AGENT}`);
|
|
131
|
+
return lines;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Health check ────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
export async function runHealthCheck() {
|
|
137
|
+
const checks = [];
|
|
138
|
+
|
|
139
|
+
const coreUp = await isPortOpen(9500);
|
|
140
|
+
if (coreUp) {
|
|
141
|
+
checks.push(c.green(" sinain-core: reachable (port 9500)"));
|
|
142
|
+
const health = await fetchHealth();
|
|
143
|
+
if (health) {
|
|
144
|
+
const audioState = health.audio?.state || health.audioPipeline?.state;
|
|
145
|
+
if (audioState === "active" || audioState === "running") {
|
|
146
|
+
checks.push(c.green(" Audio pipeline: active"));
|
|
147
|
+
} else {
|
|
148
|
+
checks.push(c.yellow(" Audio pipeline: inactive — check audio device settings"));
|
|
149
|
+
}
|
|
150
|
+
const screenActive = health.screen?.state === "active" || health.senseActive;
|
|
151
|
+
if (screenActive) {
|
|
152
|
+
checks.push(c.green(" Screen capture: active"));
|
|
153
|
+
} else {
|
|
154
|
+
if (IS_MAC) {
|
|
155
|
+
checks.push(c.yellow(" Screen capture: inactive"));
|
|
156
|
+
checks.push(c.dim(" Grant Screen Recording in System Settings → Privacy & Security"));
|
|
157
|
+
} else {
|
|
158
|
+
checks.push(c.yellow(" Screen capture: inactive"));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const gwConnected = health.escalation?.gatewayConnected;
|
|
162
|
+
if (gwConnected) {
|
|
163
|
+
checks.push(c.green(" OpenClaw gateway: connected"));
|
|
164
|
+
} else if (health.escalation?.mode === "off") {
|
|
165
|
+
checks.push(c.dim(" OpenClaw gateway: not configured (standalone mode)"));
|
|
166
|
+
} else {
|
|
167
|
+
checks.push(c.yellow(" OpenClaw gateway: not connected"));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
checks.push(c.dim(" sinain-core: not running"));
|
|
172
|
+
checks.push(c.dim(" Start with: sinain start (or ./start.sh)"));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (checks.length > 0) {
|
|
176
|
+
p.note(checks.join("\n"), "System health");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── Step functions ──────────────────────────────────────────────────────────
|
|
181
|
+
// label param: optional prefix like "[1/5]" for onboard, omitted for config
|
|
182
|
+
|
|
183
|
+
export async function stepApiKey(existing, label = "OpenRouter API key") {
|
|
184
|
+
const current = existing.OPENROUTER_API_KEY;
|
|
185
|
+
const message = current
|
|
186
|
+
? `${label} ${c.dim(`(current: ${maskKey(current)})`)}`
|
|
187
|
+
: label;
|
|
188
|
+
|
|
189
|
+
p.note(
|
|
190
|
+
`Key is stored in plain text at ${c.dim(ENV_PATH)}.\nDon't commit this file or share it.`,
|
|
191
|
+
"Security",
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const key = guard(await p.text({
|
|
195
|
+
message,
|
|
196
|
+
placeholder: current ? "Press Enter to keep current" : "sk-or-v1-...",
|
|
197
|
+
defaultValue: current || "",
|
|
198
|
+
validate: (val) => {
|
|
199
|
+
if (!val && !current) return "API key is required. Get one free at https://openrouter.ai/keys";
|
|
200
|
+
},
|
|
201
|
+
}));
|
|
202
|
+
|
|
203
|
+
return key || current;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function stepTranscription(existing, label = "Audio transcription") {
|
|
207
|
+
const current = existing.TRANSCRIPTION_BACKEND || "openrouter";
|
|
208
|
+
const hasWhisper = !IS_WINDOWS && cmdExists("whisper-cli");
|
|
209
|
+
|
|
210
|
+
const choice = guard(await p.select({
|
|
211
|
+
message: label,
|
|
212
|
+
options: [
|
|
213
|
+
{
|
|
214
|
+
value: "openrouter",
|
|
215
|
+
label: "Cloud (OpenRouter)",
|
|
216
|
+
hint: "Uses your OpenRouter key, no setup needed",
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
value: "local",
|
|
220
|
+
label: "Local (Whisper)",
|
|
221
|
+
hint: hasWhisper ? "whisper-cli detected on this machine" : "~1.5 GB download, fully private",
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
initialValue: current,
|
|
225
|
+
}));
|
|
226
|
+
|
|
227
|
+
if (choice === "local" && !hasWhisper && IS_MAC) {
|
|
228
|
+
const install = guard(await p.confirm({
|
|
229
|
+
message: "whisper-cli not found. Install via Homebrew?",
|
|
230
|
+
initialValue: true,
|
|
231
|
+
}));
|
|
232
|
+
if (install) {
|
|
233
|
+
const s = p.spinner();
|
|
234
|
+
s.start("Installing whisper-cpp...");
|
|
235
|
+
try {
|
|
236
|
+
execFileSync("brew", ["install", "whisper-cpp"], { stdio: "pipe" });
|
|
237
|
+
s.stop(c.green("whisper-cpp installed."));
|
|
238
|
+
} catch {
|
|
239
|
+
s.stop(c.yellow("Install failed — falling back to cloud."));
|
|
240
|
+
return "openrouter";
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return choice;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export async function stepGateway(existing, label = "OpenClaw gateway") {
|
|
249
|
+
p.note(
|
|
250
|
+
"The gateway gives sinain a persistent AI agent (Claude/Codex/Goose)\n" +
|
|
251
|
+
"that handles complex tasks, background research, and multi-step actions.\n" +
|
|
252
|
+
"Without it, sinain still works — HUD analysis runs locally via OpenRouter.",
|
|
253
|
+
"What is a gateway?",
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const choice = guard(await p.select({
|
|
257
|
+
message: label,
|
|
258
|
+
options: [
|
|
259
|
+
{
|
|
260
|
+
value: "skip",
|
|
261
|
+
label: "Skip / Disable",
|
|
262
|
+
hint: "HUD works fine without it — add later with sinain config",
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
value: "local",
|
|
266
|
+
label: "Local gateway",
|
|
267
|
+
hint: "Install & start openclaw on this machine",
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
value: "remote",
|
|
271
|
+
label: "Remote gateway",
|
|
272
|
+
hint: "Connect to existing gateway (URL + token)",
|
|
273
|
+
},
|
|
274
|
+
],
|
|
275
|
+
initialValue: existing.OPENCLAW_WS_URL ? "remote" : "skip",
|
|
276
|
+
}));
|
|
277
|
+
|
|
278
|
+
if (choice === "skip") {
|
|
279
|
+
return {
|
|
280
|
+
OPENCLAW_WS_URL: "",
|
|
281
|
+
OPENCLAW_HTTP_URL: "",
|
|
282
|
+
OPENCLAW_WS_TOKEN: "",
|
|
283
|
+
OPENCLAW_HTTP_TOKEN: "",
|
|
284
|
+
ESCALATION_MODE: "off",
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (choice === "local") {
|
|
289
|
+
return await setupLocalGateway(existing);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// remote
|
|
293
|
+
const wsUrl = guard(await p.text({
|
|
294
|
+
message: "Gateway WebSocket URL",
|
|
295
|
+
placeholder: "Example: ws://192.168.1.100:18789",
|
|
296
|
+
defaultValue: existing.OPENCLAW_WS_URL || "",
|
|
297
|
+
validate: (val) => {
|
|
298
|
+
if (!val) return "URL is required";
|
|
299
|
+
if (!val.startsWith("ws://") && !val.startsWith("wss://")) return "Must start with ws:// or wss://";
|
|
300
|
+
},
|
|
301
|
+
}));
|
|
302
|
+
|
|
303
|
+
const token = guard(await p.text({
|
|
304
|
+
message: "Gateway auth token (48-char hex)",
|
|
305
|
+
placeholder: existing.OPENCLAW_WS_TOKEN ? "Press Enter to keep current" : "Paste token here",
|
|
306
|
+
defaultValue: existing.OPENCLAW_WS_TOKEN || "",
|
|
307
|
+
}));
|
|
308
|
+
|
|
309
|
+
const s = p.spinner();
|
|
310
|
+
s.start("Testing gateway connection...");
|
|
311
|
+
try {
|
|
312
|
+
const parsed = new URL(wsUrl);
|
|
313
|
+
const port = parseInt(parsed.port || "18789");
|
|
314
|
+
const reachable = await isPortOpen(port);
|
|
315
|
+
if (reachable) {
|
|
316
|
+
s.stop(c.green("Gateway reachable."));
|
|
317
|
+
} else {
|
|
318
|
+
s.stop(c.yellow("Gateway not reachable — config saved anyway."));
|
|
319
|
+
}
|
|
320
|
+
} catch {
|
|
321
|
+
s.stop(c.yellow("Could not parse URL — config saved anyway."));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const httpUrl = wsUrl.replace(/^ws/, "http") + "/hooks/agent";
|
|
325
|
+
return {
|
|
326
|
+
OPENCLAW_WS_URL: wsUrl,
|
|
327
|
+
OPENCLAW_HTTP_URL: httpUrl,
|
|
328
|
+
OPENCLAW_WS_TOKEN: token,
|
|
329
|
+
OPENCLAW_HTTP_TOKEN: token,
|
|
330
|
+
OPENCLAW_SESSION_KEY: "agent:main:sinain",
|
|
331
|
+
ESCALATION_MODE: "rich",
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function setupLocalGateway(existing) {
|
|
336
|
+
const hasOpenclaw = cmdExists("openclaw");
|
|
337
|
+
|
|
338
|
+
if (!hasOpenclaw) {
|
|
339
|
+
const install = guard(await p.confirm({
|
|
340
|
+
message: "openclaw CLI not found. Install globally via npm?",
|
|
341
|
+
initialValue: true,
|
|
342
|
+
}));
|
|
343
|
+
if (install) {
|
|
344
|
+
const s = p.spinner();
|
|
345
|
+
s.start("Installing openclaw...");
|
|
346
|
+
try {
|
|
347
|
+
execFileSync("npm", ["install", "-g", "openclaw@latest"], { stdio: "pipe", timeout: 120_000 });
|
|
348
|
+
s.stop(c.green("openclaw installed."));
|
|
349
|
+
} catch {
|
|
350
|
+
s.stop(c.red("Installation failed."));
|
|
351
|
+
p.note(
|
|
352
|
+
"Install manually: npm install -g openclaw\nThen re-run setup.",
|
|
353
|
+
"Manual install",
|
|
354
|
+
);
|
|
355
|
+
return { OPENCLAW_WS_URL: "", OPENCLAW_HTTP_URL: "", ESCALATION_MODE: "off" };
|
|
356
|
+
}
|
|
357
|
+
} else {
|
|
358
|
+
return { OPENCLAW_WS_URL: "", OPENCLAW_HTTP_URL: "", ESCALATION_MODE: "off" };
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const s = p.spinner();
|
|
363
|
+
s.start("Checking gateway...");
|
|
364
|
+
const gatewayUp = await isPortOpen(18789);
|
|
365
|
+
|
|
366
|
+
const ocJsonPath = path.join(HOME, ".openclaw/openclaw.json");
|
|
367
|
+
let token = "";
|
|
368
|
+
if (fs.existsSync(ocJsonPath)) {
|
|
369
|
+
try {
|
|
370
|
+
const ocConfig = JSON.parse(fs.readFileSync(ocJsonPath, "utf-8"));
|
|
371
|
+
token = ocConfig.gateway?.auth?.token || "";
|
|
372
|
+
} catch { /* ignore */ }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (gatewayUp) {
|
|
376
|
+
s.stop(c.green("Gateway already running (port 18789)."));
|
|
377
|
+
} else {
|
|
378
|
+
s.stop(c.yellow("Gateway not running."));
|
|
379
|
+
p.note("Start your gateway:\n openclaw gateway run\n or: openclaw onboard --install-daemon", "Next step");
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
OPENCLAW_WS_URL: "ws://localhost:18789",
|
|
384
|
+
OPENCLAW_HTTP_URL: "http://localhost:18789/hooks/agent",
|
|
385
|
+
OPENCLAW_WS_TOKEN: token,
|
|
386
|
+
OPENCLAW_HTTP_TOKEN: token,
|
|
387
|
+
OPENCLAW_SESSION_KEY: "agent:main:sinain",
|
|
388
|
+
ESCALATION_MODE: "rich",
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export async function stepPrivacy(existing, label = "Privacy mode") {
|
|
393
|
+
const current = existing.PRIVACY_MODE || "standard";
|
|
394
|
+
return guard(await p.select({
|
|
395
|
+
message: label,
|
|
396
|
+
options: [
|
|
397
|
+
{
|
|
398
|
+
value: "off",
|
|
399
|
+
label: "Off",
|
|
400
|
+
hint: "No filtering — screen text, credentials, everything sent to cloud",
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
value: "standard",
|
|
404
|
+
label: "Standard",
|
|
405
|
+
hint: "Auto-redacts cards, API keys, tokens before sending to cloud",
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
value: "strict",
|
|
409
|
+
label: "Strict",
|
|
410
|
+
hint: "Only summaries leave your machine, no raw screen text or audio",
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
value: "paranoid",
|
|
414
|
+
label: "Paranoid",
|
|
415
|
+
hint: "Zero cloud calls — needs Whisper + Ollama installed or nothing works",
|
|
416
|
+
},
|
|
417
|
+
],
|
|
418
|
+
initialValue: current,
|
|
419
|
+
}));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export async function stepModel(existing, label = "AI model for HUD analysis") {
|
|
423
|
+
const current = existing.AGENT_MODEL || "google/gemini-2.5-flash-lite";
|
|
424
|
+
const knownModels = [
|
|
425
|
+
"google/gemini-2.5-flash-lite",
|
|
426
|
+
"google/gemini-2.5-flash",
|
|
427
|
+
"anthropic/claude-3.5-haiku",
|
|
428
|
+
];
|
|
429
|
+
|
|
430
|
+
const choice = guard(await p.select({
|
|
431
|
+
message: label,
|
|
432
|
+
options: [
|
|
433
|
+
{ value: "google/gemini-2.5-flash-lite", label: "gemini-2.5-flash-lite", hint: "Fastest, cheapest — recommended" },
|
|
434
|
+
{ value: "google/gemini-2.5-flash", label: "gemini-2.5-flash", hint: "Better quality, slightly more expensive" },
|
|
435
|
+
{ value: "anthropic/claude-3.5-haiku", label: "claude-3.5-haiku", hint: "Anthropic, fast and affordable" },
|
|
436
|
+
{ value: "custom", label: "Custom", hint: "Enter any model ID from openrouter.ai/models" },
|
|
437
|
+
],
|
|
438
|
+
initialValue: knownModels.includes(current) ? current : "custom",
|
|
439
|
+
}));
|
|
440
|
+
|
|
441
|
+
if (choice === "custom") {
|
|
442
|
+
return guard(await p.text({
|
|
443
|
+
message: "Model ID (from openrouter.ai/models)",
|
|
444
|
+
placeholder: "provider/model-name",
|
|
445
|
+
defaultValue: knownModels.includes(current) ? "" : current,
|
|
446
|
+
validate: (val) => {
|
|
447
|
+
if (!val) return "Model ID is required";
|
|
448
|
+
if (!val.includes("/")) return "Format: provider/model-name";
|
|
449
|
+
},
|
|
450
|
+
}));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return choice;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export async function stepAgent(existing, label = "Bare agent") {
|
|
457
|
+
const current = existing.SINAIN_AGENT || "claude";
|
|
458
|
+
return guard(await p.select({
|
|
459
|
+
message: label,
|
|
460
|
+
options: [
|
|
461
|
+
{ value: "claude", label: "Claude Code", hint: "Calls sinain tools directly — recommended" },
|
|
462
|
+
{ value: "codex", label: "Codex", hint: "Calls sinain tools directly" },
|
|
463
|
+
{ value: "goose", label: "Goose", hint: "Calls sinain tools directly" },
|
|
464
|
+
{ value: "junie", label: "Junie", hint: "JetBrains IDE agent" },
|
|
465
|
+
{ value: "aider", label: "Aider", hint: "Receives text only, no tool access" },
|
|
466
|
+
],
|
|
467
|
+
initialValue: current,
|
|
468
|
+
}));
|
|
469
|
+
}
|
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/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
|
-
|
|
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;
|