@geravant/sinain 1.3.0 → 1.5.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/cli.js +207 -26
- package/launcher.js +237 -36
- package/package.json +2 -2
- package/setup-overlay.js +38 -20
- package/sinain-agent/CLAUDE.md +1 -1
- package/sinain-agent/run.sh +43 -5
- package/sinain-core/src/escalation/escalator.ts +40 -2
- package/sinain-core/src/index.ts +15 -0
- package/sinain-core/src/overlay/commands.ts +13 -0
- package/sinain-core/src/server.ts +42 -0
- package/sinain-core/src/types.ts +7 -1
- package/sinain-mcp-server/index.ts +18 -0
- package/sinain-core/.env.example +0 -92
package/cli.js
CHANGED
|
@@ -6,6 +6,7 @@ import fs from "fs";
|
|
|
6
6
|
import path from "path";
|
|
7
7
|
|
|
8
8
|
const cmd = process.argv[2];
|
|
9
|
+
const IS_WINDOWS = os.platform() === "win32";
|
|
9
10
|
|
|
10
11
|
switch (cmd) {
|
|
11
12
|
case "start":
|
|
@@ -20,6 +21,10 @@ switch (cmd) {
|
|
|
20
21
|
await showStatus();
|
|
21
22
|
break;
|
|
22
23
|
|
|
24
|
+
case "setup":
|
|
25
|
+
await runSetupWizard();
|
|
26
|
+
break;
|
|
27
|
+
|
|
23
28
|
case "setup-overlay":
|
|
24
29
|
await import("./setup-overlay.js");
|
|
25
30
|
break;
|
|
@@ -41,39 +46,209 @@ switch (cmd) {
|
|
|
41
46
|
break;
|
|
42
47
|
}
|
|
43
48
|
|
|
49
|
+
// ── Setup wizard (standalone) ─────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
async function runSetupWizard() {
|
|
52
|
+
// Force-run the wizard even if .env exists (re-configure)
|
|
53
|
+
const { setupWizard } = await import("./launcher.js?setup-only");
|
|
54
|
+
// The wizard is embedded in launcher.js; we import the module dynamically.
|
|
55
|
+
// Since launcher.js runs main() on import, we instead inline a lightweight version.
|
|
56
|
+
|
|
57
|
+
const readline = await import("readline");
|
|
58
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
59
|
+
const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
|
|
60
|
+
|
|
61
|
+
const HOME = os.homedir();
|
|
62
|
+
const SINAIN_DIR = path.join(HOME, ".sinain");
|
|
63
|
+
const envPath = path.join(SINAIN_DIR, ".env");
|
|
64
|
+
|
|
65
|
+
const BOLD = "\x1b[1m";
|
|
66
|
+
const DIM = "\x1b[2m";
|
|
67
|
+
const GREEN = "\x1b[32m";
|
|
68
|
+
const YELLOW = "\x1b[33m";
|
|
69
|
+
const RESET = "\x1b[0m";
|
|
70
|
+
const IS_WIN = os.platform() === "win32";
|
|
71
|
+
|
|
72
|
+
const cmdExists = (cmd) => {
|
|
73
|
+
try { import("child_process").then(cp => cp.execSync(`which ${cmd}`, { stdio: "pipe" })); return true; }
|
|
74
|
+
catch { return false; }
|
|
75
|
+
};
|
|
76
|
+
// Synchronous version
|
|
77
|
+
const { execSync } = await import("child_process");
|
|
78
|
+
const cmdExistsSync = (cmd) => {
|
|
79
|
+
try { execSync(`which ${cmd}`, { stdio: "pipe" }); return true; }
|
|
80
|
+
catch { return false; }
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
if (fs.existsSync(envPath)) {
|
|
84
|
+
const overwrite = await ask(` ${envPath} already exists. Overwrite? [y/N]: `);
|
|
85
|
+
if (overwrite.trim().toLowerCase() !== "y") {
|
|
86
|
+
console.log(" Aborted.");
|
|
87
|
+
rl.close();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log();
|
|
93
|
+
console.log(`${BOLD}── Sinain Setup Wizard ─────────────────${RESET}`);
|
|
94
|
+
console.log(` Configuring ${DIM}~/.sinain/.env${RESET}`);
|
|
95
|
+
console.log();
|
|
96
|
+
|
|
97
|
+
const vars = {};
|
|
98
|
+
|
|
99
|
+
// Transcription backend
|
|
100
|
+
let transcriptionBackend = "openrouter";
|
|
101
|
+
const hasWhisper = !IS_WIN && cmdExistsSync("whisper-cli");
|
|
102
|
+
|
|
103
|
+
if (IS_WIN) {
|
|
104
|
+
console.log(` ${DIM}(Local whisper not available on Windows — using OpenRouter)${RESET}`);
|
|
105
|
+
} else if (hasWhisper) {
|
|
106
|
+
const choice = await ask(` Transcription backend? [${BOLD}local${RESET}/cloud]: `);
|
|
107
|
+
transcriptionBackend = choice.trim().toLowerCase() === "cloud" ? "openrouter" : "local";
|
|
108
|
+
} else {
|
|
109
|
+
const install = await ask(` whisper-cli not found. Install via Homebrew? [Y/n]: `);
|
|
110
|
+
if (!install.trim() || install.trim().toLowerCase() === "y") {
|
|
111
|
+
try {
|
|
112
|
+
execSync("brew install whisper-cpp", { stdio: "inherit" });
|
|
113
|
+
const modelDir = path.join(HOME, "models");
|
|
114
|
+
const modelPath = path.join(modelDir, "ggml-large-v3-turbo.bin");
|
|
115
|
+
if (!fs.existsSync(modelPath)) {
|
|
116
|
+
console.log(` ${DIM}Downloading model (~1.5 GB)...${RESET}`);
|
|
117
|
+
fs.mkdirSync(modelDir, { recursive: true });
|
|
118
|
+
execSync(`curl -L --progress-bar -o "${modelPath}" "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin"`, { stdio: "inherit" });
|
|
119
|
+
}
|
|
120
|
+
transcriptionBackend = "local";
|
|
121
|
+
vars.LOCAL_WHISPER_MODEL = modelPath;
|
|
122
|
+
} catch {
|
|
123
|
+
console.log(` ${YELLOW}Install failed — falling back to OpenRouter${RESET}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
vars.TRANSCRIPTION_BACKEND = transcriptionBackend;
|
|
128
|
+
|
|
129
|
+
// API key
|
|
130
|
+
if (transcriptionBackend === "openrouter") {
|
|
131
|
+
const key = await ask(` OpenRouter API key (sk-or-...): `);
|
|
132
|
+
if (key.trim()) vars.OPENROUTER_API_KEY = key.trim();
|
|
133
|
+
} else {
|
|
134
|
+
const key = await ask(` OpenRouter API key for vision/OCR (optional): `);
|
|
135
|
+
if (key.trim()) vars.OPENROUTER_API_KEY = key.trim();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Agent
|
|
139
|
+
const agent = await ask(` Agent? [${BOLD}claude${RESET}/codex/goose/junie/aider]: `);
|
|
140
|
+
vars.SINAIN_AGENT = agent.trim().toLowerCase() || "claude";
|
|
141
|
+
|
|
142
|
+
// Escalation
|
|
143
|
+
console.log(`\n ${DIM}Escalation: off | selective | focus | rich${RESET}`);
|
|
144
|
+
const esc = await ask(` Escalation mode? [${BOLD}selective${RESET}]: `);
|
|
145
|
+
vars.ESCALATION_MODE = esc.trim().toLowerCase() || "selective";
|
|
146
|
+
|
|
147
|
+
// Gateway
|
|
148
|
+
const gw = await ask(` OpenClaw gateway? [y/N]: `);
|
|
149
|
+
if (gw.trim().toLowerCase() === "y") {
|
|
150
|
+
const url = await ask(` Gateway WS URL [ws://localhost:18789]: `);
|
|
151
|
+
vars.OPENCLAW_WS_URL = url.trim() || "ws://localhost:18789";
|
|
152
|
+
const token = await ask(` Auth token (48-char hex): `);
|
|
153
|
+
if (token.trim()) {
|
|
154
|
+
vars.OPENCLAW_WS_TOKEN = token.trim();
|
|
155
|
+
vars.OPENCLAW_HTTP_TOKEN = token.trim();
|
|
156
|
+
}
|
|
157
|
+
vars.OPENCLAW_HTTP_URL = vars.OPENCLAW_WS_URL.replace(/^ws/, "http") + "/hooks/agent";
|
|
158
|
+
vars.OPENCLAW_SESSION_KEY = "agent:main:sinain";
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
vars.SINAIN_POLL_INTERVAL = "5";
|
|
162
|
+
vars.SINAIN_HEARTBEAT_INTERVAL = "900";
|
|
163
|
+
vars.PRIVACY_MODE = "standard";
|
|
164
|
+
|
|
165
|
+
// Write — start from .env.example template, patch wizard values in
|
|
166
|
+
fs.mkdirSync(SINAIN_DIR, { recursive: true });
|
|
167
|
+
|
|
168
|
+
const PKG_DIR = path.dirname(new URL(import.meta.url).pathname);
|
|
169
|
+
const examplePath = path.join(PKG_DIR, ".env.example");
|
|
170
|
+
const siblingExample = path.join(PKG_DIR, "..", ".env.example");
|
|
171
|
+
let template = "";
|
|
172
|
+
if (fs.existsSync(examplePath)) {
|
|
173
|
+
template = fs.readFileSync(examplePath, "utf-8");
|
|
174
|
+
} else if (fs.existsSync(siblingExample)) {
|
|
175
|
+
template = fs.readFileSync(siblingExample, "utf-8");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (template) {
|
|
179
|
+
for (const [k, v] of Object.entries(vars)) {
|
|
180
|
+
const regex = new RegExp(`^#?\\s*${k}=.*$`, "m");
|
|
181
|
+
if (regex.test(template)) {
|
|
182
|
+
template = template.replace(regex, `${k}=${v}`);
|
|
183
|
+
} else {
|
|
184
|
+
template += `\n${k}=${v}`;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
template = `# Generated by sinain setup wizard — ${new Date().toISOString()}\n${template}`;
|
|
188
|
+
fs.writeFileSync(envPath, template);
|
|
189
|
+
} else {
|
|
190
|
+
const lines = ["# sinain configuration — generated by setup wizard", `# ${new Date().toISOString()}`, ""];
|
|
191
|
+
for (const [k, v] of Object.entries(vars)) lines.push(`${k}=${v}`);
|
|
192
|
+
lines.push("");
|
|
193
|
+
fs.writeFileSync(envPath, lines.join("\n"));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
rl.close();
|
|
197
|
+
console.log(`\n ${GREEN}✓${RESET} Config written to ${envPath}\n`);
|
|
198
|
+
}
|
|
199
|
+
|
|
44
200
|
// ── Stop ──────────────────────────────────────────────────────────────────────
|
|
45
201
|
|
|
46
202
|
async function stopServices() {
|
|
47
203
|
let killed = false;
|
|
48
204
|
|
|
49
|
-
|
|
50
|
-
"tsx
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
205
|
+
if (IS_WINDOWS) {
|
|
206
|
+
const exes = ["sinain_hud.exe", "tsx.cmd", "python3.exe", "python.exe"];
|
|
207
|
+
for (const exe of exes) {
|
|
208
|
+
try {
|
|
209
|
+
execSync(`taskkill /F /IM "${exe}" 2>NUL`, { stdio: "pipe" });
|
|
210
|
+
killed = true;
|
|
211
|
+
} catch { /* not running */ }
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
const patterns = [
|
|
215
|
+
"tsx.*src/index.ts",
|
|
216
|
+
"tsx watch src/index.ts",
|
|
217
|
+
"python3 -m sense_client",
|
|
218
|
+
"Python -m sense_client",
|
|
219
|
+
"flutter run -d macos",
|
|
220
|
+
"sinain_hud.app/Contents/MacOS/sinain_hud",
|
|
221
|
+
"sinain-agent/run.sh",
|
|
222
|
+
];
|
|
223
|
+
|
|
224
|
+
for (const pat of patterns) {
|
|
225
|
+
try {
|
|
226
|
+
execSync(`pkill -f "${pat}"`, { stdio: "pipe" });
|
|
227
|
+
killed = true;
|
|
228
|
+
} catch { /* not running */ }
|
|
229
|
+
}
|
|
64
230
|
}
|
|
65
231
|
|
|
66
232
|
// Free port 9500
|
|
67
233
|
try {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
234
|
+
if (IS_WINDOWS) {
|
|
235
|
+
const out = execSync('netstat -ano | findstr ":9500" | findstr "LISTENING"', { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
236
|
+
const pid = out.split(/\s+/).pop();
|
|
237
|
+
if (pid && pid !== "0") {
|
|
238
|
+
execSync(`taskkill /F /PID ${pid}`, { stdio: "pipe" });
|
|
239
|
+
killed = true;
|
|
240
|
+
}
|
|
241
|
+
} else {
|
|
242
|
+
const pid = execSync("lsof -i :9500 -sTCP:LISTEN -t", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
243
|
+
if (pid) {
|
|
244
|
+
execSync(`kill ${pid}`, { stdio: "pipe" });
|
|
245
|
+
killed = true;
|
|
246
|
+
}
|
|
72
247
|
}
|
|
73
248
|
} catch { /* port already free */ }
|
|
74
249
|
|
|
75
250
|
// Clean PID file
|
|
76
|
-
const pidFile = "
|
|
251
|
+
const pidFile = path.join(os.tmpdir(), "sinain-pids.txt");
|
|
77
252
|
if (fs.existsSync(pidFile)) {
|
|
78
253
|
fs.unlinkSync(pidFile);
|
|
79
254
|
}
|
|
@@ -107,7 +282,7 @@ async function showStatus() {
|
|
|
107
282
|
console.log(` ${CYAN}core${RESET} :9500 ${RED}✗${RESET} stopped`);
|
|
108
283
|
}
|
|
109
284
|
|
|
110
|
-
// Sense: check
|
|
285
|
+
// Sense: check process
|
|
111
286
|
const senseUp = isProcessRunning("python3 -m sense_client") || isProcessRunning("Python -m sense_client");
|
|
112
287
|
if (senseUp) {
|
|
113
288
|
console.log(` ${YELLOW}sense${RESET} ${GREEN}✓${RESET} running`);
|
|
@@ -116,7 +291,7 @@ async function showStatus() {
|
|
|
116
291
|
}
|
|
117
292
|
|
|
118
293
|
// Overlay
|
|
119
|
-
const overlayUp = isProcessRunning("sinain_hud
|
|
294
|
+
const overlayUp = isProcessRunning("sinain_hud");
|
|
120
295
|
if (overlayUp) {
|
|
121
296
|
console.log(` ${MAGENTA}overlay${RESET} ${GREEN}✓${RESET} running`);
|
|
122
297
|
} else {
|
|
@@ -124,7 +299,7 @@ async function showStatus() {
|
|
|
124
299
|
}
|
|
125
300
|
|
|
126
301
|
// Agent
|
|
127
|
-
const agentUp = isProcessRunning("sinain-agent
|
|
302
|
+
const agentUp = isProcessRunning("sinain-agent");
|
|
128
303
|
if (agentUp) {
|
|
129
304
|
console.log(` ${GREEN}agent${RESET} ${GREEN}✓${RESET} running`);
|
|
130
305
|
} else {
|
|
@@ -147,8 +322,13 @@ function isPortOpen(port) {
|
|
|
147
322
|
|
|
148
323
|
function isProcessRunning(pattern) {
|
|
149
324
|
try {
|
|
150
|
-
|
|
151
|
-
|
|
325
|
+
if (IS_WINDOWS) {
|
|
326
|
+
const out = execSync(`tasklist /FI "IMAGENAME eq ${pattern}.exe" 2>NUL`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
|
|
327
|
+
return out.includes(pattern);
|
|
328
|
+
} else {
|
|
329
|
+
execSync(`pgrep -f "${pattern}"`, { stdio: "pipe" });
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
152
332
|
} catch {
|
|
153
333
|
return false;
|
|
154
334
|
}
|
|
@@ -158,12 +338,13 @@ function isProcessRunning(pattern) {
|
|
|
158
338
|
|
|
159
339
|
function printUsage() {
|
|
160
340
|
console.log(`
|
|
161
|
-
sinain — AI overlay system for macOS
|
|
341
|
+
sinain — AI overlay system for macOS and Windows
|
|
162
342
|
|
|
163
343
|
Usage:
|
|
164
344
|
sinain start [options] Launch sinain services
|
|
165
345
|
sinain stop Stop all sinain services
|
|
166
346
|
sinain status Check what's running
|
|
347
|
+
sinain setup Run interactive setup wizard (~/.sinain/.env)
|
|
167
348
|
sinain setup-overlay Download pre-built overlay app
|
|
168
349
|
sinain install Install OpenClaw plugin (server-side)
|
|
169
350
|
|
package/launcher.js
CHANGED
|
@@ -25,7 +25,8 @@ const RESET = "\x1b[0m";
|
|
|
25
25
|
const PKG_DIR = path.dirname(new URL(import.meta.url).pathname);
|
|
26
26
|
const HOME = os.homedir();
|
|
27
27
|
const SINAIN_DIR = path.join(HOME, ".sinain");
|
|
28
|
-
const PID_FILE = "
|
|
28
|
+
const PID_FILE = path.join(os.tmpdir(), "sinain-pids.txt");
|
|
29
|
+
const IS_WINDOWS = os.platform() === "win32";
|
|
29
30
|
|
|
30
31
|
// ── Parse flags ─────────────────────────────────────────────────────────────
|
|
31
32
|
|
|
@@ -59,6 +60,12 @@ async function main() {
|
|
|
59
60
|
await preflight();
|
|
60
61
|
console.log();
|
|
61
62
|
|
|
63
|
+
// Run setup wizard on first launch (no ~/.sinain/.env)
|
|
64
|
+
const userEnvPath = path.join(SINAIN_DIR, ".env");
|
|
65
|
+
if (!fs.existsSync(userEnvPath)) {
|
|
66
|
+
await setupWizard(userEnvPath);
|
|
67
|
+
}
|
|
68
|
+
|
|
62
69
|
// Load user config
|
|
63
70
|
loadUserEnv();
|
|
64
71
|
|
|
@@ -141,16 +148,20 @@ async function main() {
|
|
|
141
148
|
if (!skipOverlay) {
|
|
142
149
|
const overlay = findOverlay();
|
|
143
150
|
if (overlay?.type === "prebuilt") {
|
|
144
|
-
// Remove quarantine if present (ad-hoc signed app)
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
+
// Remove macOS quarantine if present (ad-hoc signed app)
|
|
152
|
+
if (!IS_WINDOWS) {
|
|
153
|
+
try {
|
|
154
|
+
const xattrs = execSync(`xattr "${overlay.path}"`, { encoding: "utf-8" });
|
|
155
|
+
if (xattrs.includes("com.apple.quarantine")) {
|
|
156
|
+
execSync(`xattr -dr com.apple.quarantine "${overlay.path}"`, { stdio: "pipe" });
|
|
157
|
+
}
|
|
158
|
+
} catch { /* no quarantine or xattr failed — try launching anyway */ }
|
|
159
|
+
}
|
|
151
160
|
|
|
152
161
|
log("Starting overlay (pre-built)...");
|
|
153
|
-
const binary =
|
|
162
|
+
const binary = IS_WINDOWS
|
|
163
|
+
? overlay.path // sinain_hud.exe
|
|
164
|
+
: path.join(overlay.path, "Contents/MacOS/sinain_hud");
|
|
154
165
|
startProcess("overlay", binary, [], { color: MAGENTA });
|
|
155
166
|
await sleep(2000);
|
|
156
167
|
const overlayChild = children.find(c => c.name === "overlay");
|
|
@@ -165,7 +176,8 @@ async function main() {
|
|
|
165
176
|
const hasFlutter = commandExists("flutter");
|
|
166
177
|
if (hasFlutter) {
|
|
167
178
|
log("Starting overlay (flutter run)...");
|
|
168
|
-
|
|
179
|
+
const device = IS_WINDOWS ? "windows" : "macos";
|
|
180
|
+
startProcess("overlay", "flutter", ["run", "-d", device], {
|
|
169
181
|
cwd: overlay.path,
|
|
170
182
|
color: MAGENTA,
|
|
171
183
|
});
|
|
@@ -259,7 +271,8 @@ async function preflight() {
|
|
|
259
271
|
ok("flutter (version unknown)");
|
|
260
272
|
}
|
|
261
273
|
} else {
|
|
262
|
-
const
|
|
274
|
+
const prebuiltName = IS_WINDOWS ? "sinain_hud.exe" : "sinain_hud.app";
|
|
275
|
+
const prebuiltApp = path.join(SINAIN_DIR, "overlay-app", prebuiltName);
|
|
263
276
|
if (fs.existsSync(prebuiltApp)) {
|
|
264
277
|
ok("overlay: pre-built app");
|
|
265
278
|
} else {
|
|
@@ -278,12 +291,179 @@ async function preflight() {
|
|
|
278
291
|
}
|
|
279
292
|
}
|
|
280
293
|
|
|
294
|
+
// ── Setup wizard ─────────────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
async function setupWizard(envPath) {
|
|
297
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
298
|
+
const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
|
|
299
|
+
|
|
300
|
+
console.log();
|
|
301
|
+
console.log(`${BOLD}── First-time setup ────────────────────${RESET}`);
|
|
302
|
+
console.log(` Configuring ${DIM}~/.sinain/.env${RESET}`);
|
|
303
|
+
console.log();
|
|
304
|
+
|
|
305
|
+
const vars = {};
|
|
306
|
+
|
|
307
|
+
// 1. Transcription backend — auto-detect whisper-cli
|
|
308
|
+
let transcriptionBackend = "openrouter";
|
|
309
|
+
const hasWhisper = !IS_WINDOWS && commandExists("whisper-cli");
|
|
310
|
+
|
|
311
|
+
if (IS_WINDOWS) {
|
|
312
|
+
console.log(` ${DIM}(Local whisper not available on Windows — using OpenRouter)${RESET}`);
|
|
313
|
+
} else if (hasWhisper) {
|
|
314
|
+
const choice = await ask(` Transcription backend? [${BOLD}local${RESET}/cloud] (local = whisper-cli, no API key): `);
|
|
315
|
+
if (choice.trim().toLowerCase() === "cloud") {
|
|
316
|
+
transcriptionBackend = "openrouter";
|
|
317
|
+
} else {
|
|
318
|
+
transcriptionBackend = "local";
|
|
319
|
+
}
|
|
320
|
+
} else {
|
|
321
|
+
const installWhisper = await ask(` whisper-cli not found. Install via Homebrew? [Y/n]: `);
|
|
322
|
+
if (!installWhisper.trim() || installWhisper.trim().toLowerCase() === "y") {
|
|
323
|
+
try {
|
|
324
|
+
console.log(` ${DIM}Installing whisper-cpp...${RESET}`);
|
|
325
|
+
execSync("brew install whisper-cpp", { stdio: "inherit" });
|
|
326
|
+
|
|
327
|
+
// Download model
|
|
328
|
+
const modelDir = path.join(HOME, "models");
|
|
329
|
+
const modelPath = path.join(modelDir, "ggml-large-v3-turbo.bin");
|
|
330
|
+
if (!fs.existsSync(modelPath)) {
|
|
331
|
+
console.log(` ${DIM}Downloading ggml-large-v3-turbo (~1.5 GB)...${RESET}`);
|
|
332
|
+
fs.mkdirSync(modelDir, { recursive: true });
|
|
333
|
+
execSync(
|
|
334
|
+
`curl -L --progress-bar -o "${modelPath}" "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin"`,
|
|
335
|
+
{ stdio: "inherit" }
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
transcriptionBackend = "local";
|
|
340
|
+
vars.LOCAL_WHISPER_MODEL = modelPath;
|
|
341
|
+
ok("whisper-cpp installed");
|
|
342
|
+
} catch {
|
|
343
|
+
warn("whisper-cpp install failed — falling back to OpenRouter");
|
|
344
|
+
transcriptionBackend = "openrouter";
|
|
345
|
+
}
|
|
346
|
+
} else {
|
|
347
|
+
transcriptionBackend = "openrouter";
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
vars.TRANSCRIPTION_BACKEND = transcriptionBackend;
|
|
351
|
+
|
|
352
|
+
// 2. OpenRouter API key (if cloud backend or for vision/OCR)
|
|
353
|
+
if (transcriptionBackend === "openrouter") {
|
|
354
|
+
let key = "";
|
|
355
|
+
while (!key) {
|
|
356
|
+
key = await ask(` OpenRouter API key (sk-or-...): `);
|
|
357
|
+
key = key.trim();
|
|
358
|
+
if (key && !key.startsWith("sk-or-")) {
|
|
359
|
+
console.log(` ${YELLOW}⚠${RESET} Key should start with sk-or-. Try again or press Enter to skip.`);
|
|
360
|
+
const retry = await ask(` Use this key anyway? [y/N]: `);
|
|
361
|
+
if (retry.trim().toLowerCase() !== "y") { key = ""; continue; }
|
|
362
|
+
}
|
|
363
|
+
if (!key) {
|
|
364
|
+
console.log(` ${DIM}You can set OPENROUTER_API_KEY later in ~/.sinain/.env${RESET}`);
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (key) vars.OPENROUTER_API_KEY = key;
|
|
369
|
+
} else {
|
|
370
|
+
// Still ask for OpenRouter key (needed for vision/OCR)
|
|
371
|
+
const key = await ask(` OpenRouter API key for vision/OCR (optional, Enter to skip): `);
|
|
372
|
+
if (key.trim()) vars.OPENROUTER_API_KEY = key.trim();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// 3. Agent selection
|
|
376
|
+
const agentChoice = await ask(` Agent? [${BOLD}claude${RESET}/codex/goose/junie/aider]: `);
|
|
377
|
+
vars.SINAIN_AGENT = agentChoice.trim().toLowerCase() || "claude";
|
|
378
|
+
|
|
379
|
+
// 4. Escalation mode
|
|
380
|
+
console.log();
|
|
381
|
+
console.log(` ${DIM}Escalation modes:${RESET}`);
|
|
382
|
+
console.log(` off — no escalation to gateway`);
|
|
383
|
+
console.log(` selective — score-based (errors, questions trigger it)`);
|
|
384
|
+
console.log(` focus — always escalate every tick`);
|
|
385
|
+
console.log(` rich — always escalate with maximum context`);
|
|
386
|
+
const escMode = await ask(` Escalation mode? [off/${BOLD}selective${RESET}/focus/rich]: `);
|
|
387
|
+
vars.ESCALATION_MODE = escMode.trim().toLowerCase() || "selective";
|
|
388
|
+
|
|
389
|
+
// 5. OpenClaw gateway
|
|
390
|
+
const hasGateway = await ask(` Do you have an OpenClaw gateway? [y/N]: `);
|
|
391
|
+
if (hasGateway.trim().toLowerCase() === "y") {
|
|
392
|
+
const wsUrl = await ask(` Gateway WebSocket URL [ws://localhost:18789]: `);
|
|
393
|
+
vars.OPENCLAW_WS_URL = wsUrl.trim() || "ws://localhost:18789";
|
|
394
|
+
|
|
395
|
+
const wsToken = await ask(` Gateway auth token (48-char hex): `);
|
|
396
|
+
if (wsToken.trim()) {
|
|
397
|
+
vars.OPENCLAW_WS_TOKEN = wsToken.trim();
|
|
398
|
+
vars.OPENCLAW_HTTP_TOKEN = wsToken.trim();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Derive HTTP URL from WS URL
|
|
402
|
+
const httpBase = vars.OPENCLAW_WS_URL.replace(/^ws/, "http");
|
|
403
|
+
vars.OPENCLAW_HTTP_URL = `${httpBase}/hooks/agent`;
|
|
404
|
+
vars.OPENCLAW_SESSION_KEY = "agent:main:sinain";
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// 6. Agent-specific defaults
|
|
408
|
+
vars.SINAIN_POLL_INTERVAL = "5";
|
|
409
|
+
vars.SINAIN_HEARTBEAT_INTERVAL = "900";
|
|
410
|
+
vars.PRIVACY_MODE = "standard";
|
|
411
|
+
|
|
412
|
+
// Write .env — start from .env.example template, patch wizard values in
|
|
413
|
+
fs.mkdirSync(path.dirname(envPath), { recursive: true });
|
|
414
|
+
|
|
415
|
+
const examplePath = path.join(PKG_DIR, ".env.example");
|
|
416
|
+
let template = "";
|
|
417
|
+
if (fs.existsSync(examplePath)) {
|
|
418
|
+
template = fs.readFileSync(examplePath, "utf-8");
|
|
419
|
+
} else {
|
|
420
|
+
// Fallback: try sibling (running from cloned repo)
|
|
421
|
+
const siblingExample = path.join(PKG_DIR, "..", ".env.example");
|
|
422
|
+
if (fs.existsSync(siblingExample)) {
|
|
423
|
+
template = fs.readFileSync(siblingExample, "utf-8");
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (template) {
|
|
428
|
+
// Patch each wizard var into the template by replacing the KEY=... line
|
|
429
|
+
for (const [key, val] of Object.entries(vars)) {
|
|
430
|
+
// Match KEY=anything (possibly commented out with #)
|
|
431
|
+
const regex = new RegExp(`^#?\\s*${key}=.*$`, "m");
|
|
432
|
+
if (regex.test(template)) {
|
|
433
|
+
template = template.replace(regex, `${key}=${val}`);
|
|
434
|
+
} else {
|
|
435
|
+
// Key not in template — append it
|
|
436
|
+
template += `\n${key}=${val}`;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
// Add wizard timestamp header
|
|
440
|
+
template = `# Generated by sinain setup wizard — ${new Date().toISOString()}\n${template}`;
|
|
441
|
+
fs.writeFileSync(envPath, template);
|
|
442
|
+
} else {
|
|
443
|
+
// No template found — write bare vars (fallback)
|
|
444
|
+
const lines = [];
|
|
445
|
+
lines.push("# sinain configuration — generated by setup wizard");
|
|
446
|
+
lines.push(`# ${new Date().toISOString()}`);
|
|
447
|
+
lines.push("");
|
|
448
|
+
for (const [key, val] of Object.entries(vars)) {
|
|
449
|
+
lines.push(`${key}=${val}`);
|
|
450
|
+
}
|
|
451
|
+
lines.push("");
|
|
452
|
+
fs.writeFileSync(envPath, lines.join("\n"));
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
rl.close();
|
|
456
|
+
|
|
457
|
+
console.log();
|
|
458
|
+
ok(`Config written to ${envPath}`);
|
|
459
|
+
console.log();
|
|
460
|
+
}
|
|
461
|
+
|
|
281
462
|
// ── User environment ────────────────────────────────────────────────────────
|
|
282
463
|
|
|
283
464
|
function loadUserEnv() {
|
|
284
465
|
const envPaths = [
|
|
285
466
|
path.join(SINAIN_DIR, ".env"),
|
|
286
|
-
path.join(PKG_DIR, "sinain-core/.env"),
|
|
287
467
|
];
|
|
288
468
|
|
|
289
469
|
for (const envPath of envPaths) {
|
|
@@ -373,31 +553,51 @@ async function installDeps() {
|
|
|
373
553
|
|
|
374
554
|
function killStale() {
|
|
375
555
|
let killed = false;
|
|
376
|
-
const patterns = [
|
|
377
|
-
"sinain_hud.app/Contents/MacOS/sinain_hud",
|
|
378
|
-
"flutter run -d macos",
|
|
379
|
-
"python3 -m sense_client",
|
|
380
|
-
"Python -m sense_client",
|
|
381
|
-
"tsx.*src/index.ts",
|
|
382
|
-
"tsx watch src/index.ts",
|
|
383
|
-
"sinain-agent/run.sh",
|
|
384
|
-
];
|
|
385
556
|
|
|
386
|
-
|
|
557
|
+
if (IS_WINDOWS) {
|
|
558
|
+
const exes = ["sinain_hud.exe", "tsx.cmd"];
|
|
559
|
+
for (const exe of exes) {
|
|
560
|
+
try {
|
|
561
|
+
execSync(`taskkill /F /IM "${exe}" 2>NUL`, { stdio: "pipe" });
|
|
562
|
+
killed = true;
|
|
563
|
+
} catch { /* not running */ }
|
|
564
|
+
}
|
|
565
|
+
// Free port 9500
|
|
387
566
|
try {
|
|
388
|
-
execSync(
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
567
|
+
const out = execSync('netstat -ano | findstr ":9500" | findstr "LISTENING"', { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
568
|
+
const pid = out.split(/\s+/).pop();
|
|
569
|
+
if (pid && pid !== "0") {
|
|
570
|
+
execSync(`taskkill /F /PID ${pid}`, { stdio: "pipe" });
|
|
571
|
+
killed = true;
|
|
572
|
+
}
|
|
573
|
+
} catch { /* already free */ }
|
|
574
|
+
} else {
|
|
575
|
+
const patterns = [
|
|
576
|
+
"sinain_hud.app/Contents/MacOS/sinain_hud",
|
|
577
|
+
"flutter run -d macos",
|
|
578
|
+
"python3 -m sense_client",
|
|
579
|
+
"Python -m sense_client",
|
|
580
|
+
"tsx.*src/index.ts",
|
|
581
|
+
"tsx watch src/index.ts",
|
|
582
|
+
"sinain-agent/run.sh",
|
|
583
|
+
];
|
|
584
|
+
|
|
585
|
+
for (const pat of patterns) {
|
|
586
|
+
try {
|
|
587
|
+
execSync(`pkill -f "${pat}"`, { stdio: "pipe" });
|
|
588
|
+
killed = true;
|
|
589
|
+
} catch { /* not running */ }
|
|
399
590
|
}
|
|
400
|
-
|
|
591
|
+
|
|
592
|
+
// Free port 9500
|
|
593
|
+
try {
|
|
594
|
+
const pid = execSync("lsof -i :9500 -sTCP:LISTEN -t", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
595
|
+
if (pid) {
|
|
596
|
+
execSync(`kill ${pid}`, { stdio: "pipe" });
|
|
597
|
+
killed = true;
|
|
598
|
+
}
|
|
599
|
+
} catch { /* already free */ }
|
|
600
|
+
}
|
|
401
601
|
|
|
402
602
|
// Clean old PID file
|
|
403
603
|
if (fs.existsSync(PID_FILE)) {
|
|
@@ -496,8 +696,9 @@ function findOverlay() {
|
|
|
496
696
|
return { type: "source", path: siblingOverlay };
|
|
497
697
|
}
|
|
498
698
|
|
|
499
|
-
// 2. Pre-built
|
|
500
|
-
const
|
|
699
|
+
// 2. Pre-built app (downloaded by setup-overlay)
|
|
700
|
+
const prebuiltName = IS_WINDOWS ? "sinain_hud.exe" : "sinain_hud.app";
|
|
701
|
+
const prebuiltApp = path.join(SINAIN_DIR, "overlay-app", prebuiltName);
|
|
501
702
|
if (fs.existsSync(prebuiltApp)) {
|
|
502
703
|
return { type: "prebuilt", path: prebuiltApp };
|
|
503
704
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geravant/sinain",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Ambient AI overlay invisible to screen capture — real-time insights from audio + screen context",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"sinain-core/package.json",
|
|
27
27
|
"sinain-core/package-lock.json",
|
|
28
28
|
"sinain-core/tsconfig.json",
|
|
29
|
-
"
|
|
29
|
+
".env.example",
|
|
30
30
|
"sinain-mcp-server/index.ts",
|
|
31
31
|
"sinain-mcp-server/package.json",
|
|
32
32
|
"sinain-mcp-server/tsconfig.json",
|
package/setup-overlay.js
CHANGED
|
@@ -9,8 +9,14 @@ import os from "os";
|
|
|
9
9
|
const HOME = os.homedir();
|
|
10
10
|
const SINAIN_DIR = path.join(HOME, ".sinain");
|
|
11
11
|
const APP_DIR = path.join(SINAIN_DIR, "overlay-app");
|
|
12
|
-
const APP_PATH = path.join(APP_DIR, "sinain_hud.app");
|
|
13
12
|
const VERSION_FILE = path.join(APP_DIR, "version.json");
|
|
13
|
+
const IS_WINDOWS = os.platform() === "win32";
|
|
14
|
+
|
|
15
|
+
// Platform-specific asset and app path
|
|
16
|
+
const ASSET_NAME = IS_WINDOWS ? "sinain_hud_windows.zip" : "sinain_hud.app.zip";
|
|
17
|
+
const APP_PATH = IS_WINDOWS
|
|
18
|
+
? path.join(APP_DIR, "sinain_hud.exe")
|
|
19
|
+
: path.join(APP_DIR, "sinain_hud.app");
|
|
14
20
|
|
|
15
21
|
const REPO = "anthillnet/sinain-hud";
|
|
16
22
|
const RELEASES_API = `https://api.github.com/repos/${REPO}/releases`;
|
|
@@ -79,15 +85,15 @@ async function downloadPrebuilt() {
|
|
|
79
85
|
} catch { /* corrupt version file — re-download */ }
|
|
80
86
|
}
|
|
81
87
|
|
|
82
|
-
// Find the .zip asset
|
|
83
|
-
const zipAsset = release.assets?.find(a => a.name ===
|
|
88
|
+
// Find the .zip asset for this platform
|
|
89
|
+
const zipAsset = release.assets?.find(a => a.name === ASSET_NAME);
|
|
84
90
|
if (!zipAsset) {
|
|
85
|
-
fail(`Release ${tag} has no
|
|
91
|
+
fail(`Release ${tag} has no ${ASSET_NAME} asset.\n Try: sinain setup-overlay --from-source`);
|
|
86
92
|
}
|
|
87
93
|
|
|
88
94
|
// Download with progress
|
|
89
|
-
log(`Downloading overlay ${version} (${formatBytes(zipAsset.size)})...`);
|
|
90
|
-
const zipPath = path.join(APP_DIR,
|
|
95
|
+
log(`Downloading overlay ${version} for ${IS_WINDOWS ? "Windows" : "macOS"} (${formatBytes(zipAsset.size)})...`);
|
|
96
|
+
const zipPath = path.join(APP_DIR, ASSET_NAME);
|
|
91
97
|
|
|
92
98
|
try {
|
|
93
99
|
const res = await fetch(zipAsset.browser_download_url, {
|
|
@@ -125,23 +131,34 @@ async function downloadPrebuilt() {
|
|
|
125
131
|
fs.rmSync(APP_PATH, { recursive: true, force: true });
|
|
126
132
|
}
|
|
127
133
|
|
|
128
|
-
// Extract
|
|
134
|
+
// Extract
|
|
129
135
|
log("Extracting...");
|
|
130
|
-
|
|
131
|
-
execSync(`ditto -x -k "${zipPath}" "${APP_DIR}"`, { stdio: "pipe" });
|
|
132
|
-
} catch {
|
|
133
|
-
// Fallback to unzip
|
|
136
|
+
if (IS_WINDOWS) {
|
|
134
137
|
try {
|
|
135
|
-
execSync(
|
|
138
|
+
execSync(
|
|
139
|
+
`powershell -Command "Expand-Archive -Path '${zipPath}' -DestinationPath '${APP_DIR}' -Force"`,
|
|
140
|
+
{ stdio: "pipe" }
|
|
141
|
+
);
|
|
136
142
|
} catch (e) {
|
|
137
143
|
fail(`Extraction failed: ${e.message}`);
|
|
138
144
|
}
|
|
139
|
-
}
|
|
145
|
+
} else {
|
|
146
|
+
// ditto preserves macOS extended attributes (critical for code signing)
|
|
147
|
+
try {
|
|
148
|
+
execSync(`ditto -x -k "${zipPath}" "${APP_DIR}"`, { stdio: "pipe" });
|
|
149
|
+
} catch {
|
|
150
|
+
try {
|
|
151
|
+
execSync(`unzip -o -q "${zipPath}" -d "${APP_DIR}"`, { stdio: "pipe" });
|
|
152
|
+
} catch (e) {
|
|
153
|
+
fail(`Extraction failed: ${e.message}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
140
156
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
157
|
+
// Remove quarantine attribute (ad-hoc signed app downloaded from internet)
|
|
158
|
+
try {
|
|
159
|
+
execSync(`xattr -cr "${APP_PATH}"`, { stdio: "pipe" });
|
|
160
|
+
} catch { /* xattr may not be needed */ }
|
|
161
|
+
}
|
|
145
162
|
|
|
146
163
|
// Write version marker
|
|
147
164
|
fs.writeFileSync(VERSION_FILE, JSON.stringify({
|
|
@@ -203,8 +220,9 @@ async function buildFromSource() {
|
|
|
203
220
|
log("Installing Flutter dependencies...");
|
|
204
221
|
execSync("flutter pub get", { cwd: overlayDir, stdio: "inherit" });
|
|
205
222
|
|
|
206
|
-
|
|
207
|
-
|
|
223
|
+
const buildTarget = IS_WINDOWS ? "windows" : "macos";
|
|
224
|
+
log(`Building overlay for ${buildTarget} (this may take a few minutes)...`);
|
|
225
|
+
execSync(`flutter build ${buildTarget}`, { cwd: overlayDir, stdio: "inherit" });
|
|
208
226
|
ok("Overlay built successfully");
|
|
209
227
|
|
|
210
228
|
// Symlink ~/.sinain/overlay → the overlay source dir
|
|
@@ -221,7 +239,7 @@ async function buildFromSource() {
|
|
|
221
239
|
console.log(`
|
|
222
240
|
${GREEN}✓${RESET} Overlay setup complete!
|
|
223
241
|
The overlay will auto-start with: ${BOLD}sinain start${RESET}
|
|
224
|
-
Or run manually: cd ${overlayDir} && flutter run -d macos
|
|
242
|
+
Or run manually: cd ${overlayDir} && flutter run -d ${IS_WINDOWS ? "windows" : "macos"}
|
|
225
243
|
`);
|
|
226
244
|
}
|
|
227
245
|
|
package/sinain-agent/CLAUDE.md
CHANGED
|
@@ -82,6 +82,6 @@ Your working memory lives at `~/.openclaw/workspace/memory/`:
|
|
|
82
82
|
|
|
83
83
|
## Privacy
|
|
84
84
|
|
|
85
|
-
The HUD overlay is invisible to screen capture. All content you receive has already been privacy-stripped by sinain-core. Your responses appear only on the
|
|
85
|
+
The HUD overlay is invisible to screen capture. All content you receive has already been privacy-stripped by sinain-core. Your responses appear only on the invisible overlay — they are never captured in screenshots or recordings.
|
|
86
86
|
|
|
87
87
|
Never include `<private>` tagged content in your responses — it will be stripped automatically, but avoid echoing it.
|
package/sinain-agent/run.sh
CHANGED
|
@@ -3,12 +3,19 @@ set -euo pipefail
|
|
|
3
3
|
|
|
4
4
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
5
5
|
|
|
6
|
-
# Load .env
|
|
6
|
+
# Load .env as fallback — does NOT override vars already in the environment
|
|
7
|
+
# (e.g. vars set by the launcher from ~/.sinain/.env)
|
|
7
8
|
if [ -f "$SCRIPT_DIR/.env" ]; then
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
while IFS='=' read -r key val; do
|
|
10
|
+
# Skip comments and blank lines
|
|
11
|
+
[[ -z "$key" || "$key" =~ ^[[:space:]]*# ]] && continue
|
|
12
|
+
key=$(echo "$key" | xargs) # trim whitespace
|
|
13
|
+
val=$(echo "$val" | xargs)
|
|
14
|
+
# Only set if not already in environment
|
|
15
|
+
if [ -z "${!key+x}" ]; then
|
|
16
|
+
export "$key=$val"
|
|
17
|
+
fi
|
|
18
|
+
done < "$SCRIPT_DIR/.env"
|
|
12
19
|
fi
|
|
13
20
|
|
|
14
21
|
MCP_CONFIG="${MCP_CONFIG:-$SCRIPT_DIR/mcp-config.json}"
|
|
@@ -217,6 +224,37 @@ while true; do
|
|
|
217
224
|
echo ""
|
|
218
225
|
fi
|
|
219
226
|
|
|
227
|
+
# Poll for pending spawn task (queued via HUD Shift+Enter or POST /spawn)
|
|
228
|
+
SPAWN=$(curl -sf "$CORE_URL/spawn/pending" 2>/dev/null || echo '{"ok":false}')
|
|
229
|
+
SPAWN_ID=$(echo "$SPAWN" | python3 -c "import sys,json; d=json.load(sys.stdin); t=d.get('task'); print(t['id'] if t else '')" 2>/dev/null || true)
|
|
230
|
+
|
|
231
|
+
if [ -n "$SPAWN_ID" ]; then
|
|
232
|
+
SPAWN_TASK=$(echo "$SPAWN" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['task']['task'])" 2>/dev/null)
|
|
233
|
+
SPAWN_LABEL=$(echo "$SPAWN" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['task'].get('label','task'))" 2>/dev/null)
|
|
234
|
+
|
|
235
|
+
echo "[$(date +%H:%M:%S)] Spawn task $SPAWN_ID ($SPAWN_LABEL)"
|
|
236
|
+
|
|
237
|
+
if agent_has_mcp; then
|
|
238
|
+
# MCP path: agent runs task with sinain tools available
|
|
239
|
+
SPAWN_PROMPT="You have a background task to complete. Task: $SPAWN_TASK
|
|
240
|
+
|
|
241
|
+
Complete this task thoroughly. Use sinain_get_knowledge and sinain_knowledge_query if you need context from past sessions. Summarize your findings concisely."
|
|
242
|
+
SPAWN_RESULT=$(invoke_agent "$SPAWN_PROMPT" || echo "ERROR: agent invocation failed")
|
|
243
|
+
else
|
|
244
|
+
# Pipe path: agent gets task text directly
|
|
245
|
+
SPAWN_RESULT=$(invoke_pipe "Background task: $SPAWN_TASK" || echo "No output")
|
|
246
|
+
fi
|
|
247
|
+
|
|
248
|
+
# Post result back
|
|
249
|
+
if [ -n "$SPAWN_RESULT" ]; then
|
|
250
|
+
curl -sf -X POST "$CORE_URL/spawn/respond" \
|
|
251
|
+
-H 'Content-Type: application/json' \
|
|
252
|
+
-d "{\"id\":\"$SPAWN_ID\",\"result\":$(echo "$SPAWN_RESULT" | json_encode)}" >/dev/null 2>&1 || true
|
|
253
|
+
echo "[$(date +%H:%M:%S)] Spawn $SPAWN_ID completed: ${SPAWN_RESULT:0:120}..."
|
|
254
|
+
fi
|
|
255
|
+
echo ""
|
|
256
|
+
fi
|
|
257
|
+
|
|
220
258
|
# Heartbeat check
|
|
221
259
|
NOW=$(date +%s)
|
|
222
260
|
ELAPSED=$((NOW - LAST_HEARTBEAT))
|
|
@@ -78,6 +78,9 @@ export class Escalator {
|
|
|
78
78
|
private pendingUserCommand: UserCommand | null = null;
|
|
79
79
|
private static readonly USER_COMMAND_EXPIRY_MS = 120_000; // 2 minutes
|
|
80
80
|
|
|
81
|
+
// HTTP spawn queue — for bare agents that poll (mirrors httpPending for escalation)
|
|
82
|
+
private spawnHttpPending: { id: string; task: string; label: string; ts: number } | null = null;
|
|
83
|
+
|
|
81
84
|
private stats = {
|
|
82
85
|
totalEscalations: 0,
|
|
83
86
|
totalResponses: 0,
|
|
@@ -397,6 +400,37 @@ ${recentLines.join("\n")}`;
|
|
|
397
400
|
return { ok: true };
|
|
398
401
|
}
|
|
399
402
|
|
|
403
|
+
/** Return the current HTTP pending spawn task (or null). */
|
|
404
|
+
getSpawnPending(): { id: string; task: string; label: string; ts: number } | null {
|
|
405
|
+
return this.spawnHttpPending;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Respond to a pending spawn task from a bare agent. */
|
|
409
|
+
respondSpawn(id: string, result: string): { ok: boolean; error?: string } {
|
|
410
|
+
if (!this.spawnHttpPending) {
|
|
411
|
+
return { ok: false, error: "no pending spawn task" };
|
|
412
|
+
}
|
|
413
|
+
if (this.spawnHttpPending.id !== id) {
|
|
414
|
+
return { ok: false, error: `id mismatch: expected ${this.spawnHttpPending.id}` };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const label = this.spawnHttpPending.label;
|
|
418
|
+
const startedAt = this.spawnHttpPending.ts;
|
|
419
|
+
|
|
420
|
+
// Push result to HUD feed
|
|
421
|
+
const maxLen = 3000;
|
|
422
|
+
const text = `[🔧 ${label}] ${result.trim().slice(0, maxLen)}`;
|
|
423
|
+
this.deps.feedBuffer.push(text, "high", "openclaw", "agent");
|
|
424
|
+
this.deps.wsHandler.broadcast(text, "high", "agent");
|
|
425
|
+
|
|
426
|
+
// Broadcast completion
|
|
427
|
+
this.broadcastTaskEvent(id, "completed", label, startedAt, result.slice(0, 200));
|
|
428
|
+
|
|
429
|
+
log(TAG, `spawn ${id} responded (${result.length} chars)`);
|
|
430
|
+
this.spawnHttpPending = null;
|
|
431
|
+
return { ok: true };
|
|
432
|
+
}
|
|
433
|
+
|
|
400
434
|
/** Whether the gateway WS client is currently connected. */
|
|
401
435
|
get isGatewayConnected(): boolean {
|
|
402
436
|
return this.wsClient.isConnected;
|
|
@@ -468,8 +502,12 @@ ${recentLines.join("\n")}`;
|
|
|
468
502
|
this.broadcastTaskEvent(taskId, "spawned", label, startedAt);
|
|
469
503
|
|
|
470
504
|
if (!this.wsClient.isConnected) {
|
|
471
|
-
|
|
472
|
-
this.
|
|
505
|
+
// No OpenClaw gateway — queue for bare agent HTTP polling
|
|
506
|
+
this.spawnHttpPending = { id: taskId, task, label: label || "background-task", ts: startedAt };
|
|
507
|
+
const preview = task.length > 60 ? task.slice(0, 60) + "…" : task;
|
|
508
|
+
this.deps.feedBuffer.push(`🔧 Task queued for agent: ${preview}`, "normal", "system", "stream");
|
|
509
|
+
this.deps.wsHandler.broadcast(`🔧 Task queued for agent: ${preview}`, "normal");
|
|
510
|
+
log(TAG, `spawn-task ${taskId}: WS disconnected — queued for bare agent polling`);
|
|
473
511
|
return;
|
|
474
512
|
}
|
|
475
513
|
|
package/sinain-core/src/index.ts
CHANGED
|
@@ -416,6 +416,15 @@ async function main() {
|
|
|
416
416
|
return execFileSync("python3", args, { timeout: 5000, encoding: "utf-8" });
|
|
417
417
|
} catch { return ""; }
|
|
418
418
|
},
|
|
419
|
+
|
|
420
|
+
// Spawn background agent task (from HUD Shift+Enter or bare agent POST /spawn)
|
|
421
|
+
onSpawnCommand: (text: string) => {
|
|
422
|
+
escalator.dispatchSpawnTask(text, "user-command").catch((err) => {
|
|
423
|
+
log("srv", `spawn via HTTP failed: ${err}`);
|
|
424
|
+
});
|
|
425
|
+
},
|
|
426
|
+
getSpawnPending: () => escalator.getSpawnPending(),
|
|
427
|
+
respondSpawn: (id: string, result: string) => escalator.respondSpawn(id, result),
|
|
419
428
|
});
|
|
420
429
|
|
|
421
430
|
// ── Wire overlay profiling ──
|
|
@@ -435,6 +444,12 @@ async function main() {
|
|
|
435
444
|
onUserCommand: (text) => {
|
|
436
445
|
escalator.setUserCommand(text);
|
|
437
446
|
},
|
|
447
|
+
onSpawnCommand: (text) => {
|
|
448
|
+
escalator.dispatchSpawnTask(text, "user-command").catch((err) => {
|
|
449
|
+
log("cmd", `spawn command failed: ${err}`);
|
|
450
|
+
wsHandler.broadcast(`\u26a0 Spawn failed: ${String(err).slice(0, 100)}`, "normal");
|
|
451
|
+
});
|
|
452
|
+
},
|
|
438
453
|
onToggleScreen: () => {
|
|
439
454
|
screenActive = !screenActive;
|
|
440
455
|
if (!screenActive) {
|
|
@@ -15,6 +15,8 @@ export interface CommandDeps {
|
|
|
15
15
|
onUserMessage: (text: string) => Promise<void>;
|
|
16
16
|
/** Queue a user command to augment the next escalation */
|
|
17
17
|
onUserCommand: (text: string) => void;
|
|
18
|
+
/** Spawn a background agent task */
|
|
19
|
+
onSpawnCommand?: (text: string) => void;
|
|
18
20
|
/** Toggle screen capture — returns new state */
|
|
19
21
|
onToggleScreen: () => boolean;
|
|
20
22
|
/** Toggle trait voices — returns new enabled state */
|
|
@@ -44,6 +46,17 @@ export function setupCommands(deps: CommandDeps): void {
|
|
|
44
46
|
deps.onUserCommand(msg.text);
|
|
45
47
|
break;
|
|
46
48
|
}
|
|
49
|
+
case "spawn_command": {
|
|
50
|
+
const preview = msg.text.length > 60 ? msg.text.slice(0, 60) + "…" : msg.text;
|
|
51
|
+
log(TAG, `spawn command received: "${preview}"`);
|
|
52
|
+
if (deps.onSpawnCommand) {
|
|
53
|
+
deps.onSpawnCommand(msg.text);
|
|
54
|
+
} else {
|
|
55
|
+
log(TAG, `spawn command ignored — no handler configured`);
|
|
56
|
+
wsHandler.broadcast(`⚠ Spawn not available (no agent gateway connected)`, "normal");
|
|
57
|
+
}
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
47
60
|
case "command": {
|
|
48
61
|
handleCommand(msg.action, deps);
|
|
49
62
|
log(TAG, `command processed: ${msg.action}`);
|
|
@@ -39,6 +39,9 @@ export interface ServerDeps {
|
|
|
39
39
|
respondEscalation?: (id: string, response: string) => any;
|
|
40
40
|
getKnowledgeDocPath?: () => string | null;
|
|
41
41
|
queryKnowledgeFacts?: (entities: string[], maxFacts: number) => Promise<string>;
|
|
42
|
+
onSpawnCommand?: (text: string) => void;
|
|
43
|
+
getSpawnPending?: () => { id: string; task: string; label: string; ts: number } | null;
|
|
44
|
+
respondSpawn?: (id: string, result: string) => { ok: boolean; error?: string };
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
function readBody(req: IncomingMessage, maxBytes: number): Promise<string> {
|
|
@@ -341,6 +344,45 @@ export function createAppServer(deps: ServerDeps) {
|
|
|
341
344
|
return;
|
|
342
345
|
}
|
|
343
346
|
|
|
347
|
+
// ── /spawn ──
|
|
348
|
+
if (req.method === "POST" && url.pathname === "/spawn") {
|
|
349
|
+
const body = await readBody(req, 65536);
|
|
350
|
+
const { text, label } = JSON.parse(body);
|
|
351
|
+
if (!text) {
|
|
352
|
+
res.writeHead(400);
|
|
353
|
+
res.end(JSON.stringify({ ok: false, error: "missing text" }));
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (deps.onSpawnCommand) {
|
|
357
|
+
deps.onSpawnCommand(text);
|
|
358
|
+
res.end(JSON.stringify({ ok: true, spawned: true }));
|
|
359
|
+
} else {
|
|
360
|
+
res.end(JSON.stringify({ ok: false, error: "spawn not configured" }));
|
|
361
|
+
}
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ── /spawn/pending (bare agent polls for queued tasks) ──
|
|
366
|
+
if (req.method === "GET" && url.pathname === "/spawn/pending") {
|
|
367
|
+
const task = deps.getSpawnPending?.() ?? null;
|
|
368
|
+
res.end(JSON.stringify({ ok: true, task }));
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ── /spawn/respond (bare agent returns task result) ──
|
|
373
|
+
if (req.method === "POST" && url.pathname === "/spawn/respond") {
|
|
374
|
+
const body = await readBody(req, 65536);
|
|
375
|
+
const { id, result } = JSON.parse(body);
|
|
376
|
+
if (!id || !result) {
|
|
377
|
+
res.writeHead(400);
|
|
378
|
+
res.end(JSON.stringify({ ok: false, error: "missing id or result" }));
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const resp = deps.respondSpawn?.(id, result) ?? { ok: false, error: "spawn not configured" };
|
|
382
|
+
res.end(JSON.stringify(resp));
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
344
386
|
res.writeHead(404);
|
|
345
387
|
res.end(JSON.stringify({ error: "not found" }));
|
|
346
388
|
} catch (err: any) {
|
package/sinain-core/src/types.ts
CHANGED
|
@@ -72,8 +72,14 @@ export interface UserCommandMessage {
|
|
|
72
72
|
text: string;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/** Overlay → sinain-core: spawn a background agent task */
|
|
76
|
+
export interface SpawnCommandMessage {
|
|
77
|
+
type: "spawn_command";
|
|
78
|
+
text: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
75
81
|
export type OutboundMessage = FeedMessage | StatusMessage | PingMessage | SpawnTaskMessage;
|
|
76
|
-
export type InboundMessage = UserMessage | CommandMessage | PongMessage | ProfilingMessage | UserCommandMessage;
|
|
82
|
+
export type InboundMessage = UserMessage | CommandMessage | PongMessage | ProfilingMessage | UserCommandMessage | SpawnCommandMessage;
|
|
77
83
|
|
|
78
84
|
/** Abstraction for user commands (text now, voice later). */
|
|
79
85
|
export interface UserCommand {
|
|
@@ -152,6 +152,24 @@ server.tool(
|
|
|
152
152
|
);
|
|
153
153
|
|
|
154
154
|
// 6. sinain_post_feed
|
|
155
|
+
// 6b. sinain_spawn
|
|
156
|
+
server.tool(
|
|
157
|
+
"sinain_spawn",
|
|
158
|
+
"Spawn a background agent task via sinain-core",
|
|
159
|
+
{
|
|
160
|
+
task: z.string(),
|
|
161
|
+
label: z.string().optional().default("background-task"),
|
|
162
|
+
},
|
|
163
|
+
async ({ task, label }) => {
|
|
164
|
+
try {
|
|
165
|
+
const data = await coreRequest("POST", "/spawn", { text: task, label });
|
|
166
|
+
return textResult(JSON.stringify(data, null, 2));
|
|
167
|
+
} catch (err: any) {
|
|
168
|
+
return textResult(`Error spawning task: ${err.message}`);
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
);
|
|
172
|
+
|
|
155
173
|
server.tool(
|
|
156
174
|
"sinain_post_feed",
|
|
157
175
|
"Post a message to the sinain-core HUD feed",
|
package/sinain-core/.env.example
DELETED
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
# sinain-core configuration
|
|
2
|
-
# Copy to .env and fill in your values: cp .env.example .env
|
|
3
|
-
|
|
4
|
-
# ── Server ──
|
|
5
|
-
PORT=9500
|
|
6
|
-
|
|
7
|
-
# ── System Audio ──
|
|
8
|
-
# Default: ScreenCaptureKit (zero-setup, macOS 13+). Fallback: ffmpeg + BlackHole.
|
|
9
|
-
AUDIO_CAPTURE_CMD=screencapturekit # screencapturekit | sox | ffmpeg
|
|
10
|
-
AUDIO_DEVICE=BlackHole 2ch # macOS audio device (only used by sox/ffmpeg)
|
|
11
|
-
AUDIO_SAMPLE_RATE=16000
|
|
12
|
-
AUDIO_CHUNK_MS=5000
|
|
13
|
-
AUDIO_VAD_ENABLED=true
|
|
14
|
-
AUDIO_VAD_THRESHOLD=0.003
|
|
15
|
-
AUDIO_AUTO_START=true
|
|
16
|
-
AUDIO_GAIN_DB=20
|
|
17
|
-
|
|
18
|
-
# ── Microphone (opt-in for privacy) ──
|
|
19
|
-
MIC_ENABLED=false # set true to capture user's microphone
|
|
20
|
-
MIC_DEVICE=default # "default" = system mic. For specific device: use exact CoreAudio name
|
|
21
|
-
MIC_CAPTURE_CMD=sox # sox or ffmpeg (mic uses sox by default)
|
|
22
|
-
MIC_SAMPLE_RATE=16000
|
|
23
|
-
MIC_CHUNK_MS=5000
|
|
24
|
-
MIC_VAD_ENABLED=true
|
|
25
|
-
MIC_VAD_THRESHOLD=0.008 # higher threshold (ambient noise)
|
|
26
|
-
MIC_AUTO_START=false
|
|
27
|
-
MIC_GAIN_DB=0
|
|
28
|
-
|
|
29
|
-
# ── Transcription ──
|
|
30
|
-
OPENROUTER_API_KEY= # required (unless TRANSCRIPTION_BACKEND=local)
|
|
31
|
-
TRANSCRIPTION_BACKEND=openrouter # openrouter | local (local = whisper.cpp on-device)
|
|
32
|
-
TRANSCRIPTION_MODEL=google/gemini-2.5-flash
|
|
33
|
-
TRANSCRIPTION_LANGUAGE=en-US
|
|
34
|
-
|
|
35
|
-
# ── Local Transcription (only when TRANSCRIPTION_BACKEND=local) ──
|
|
36
|
-
# Install: brew install whisper-cpp
|
|
37
|
-
# Models: https://huggingface.co/ggerganov/whisper.cpp/tree/main
|
|
38
|
-
# LOCAL_WHISPER_BIN=whisper-cli
|
|
39
|
-
# LOCAL_WHISPER_MODEL=~/models/ggml-large-v3-turbo.bin
|
|
40
|
-
# LOCAL_WHISPER_TIMEOUT_MS=15000
|
|
41
|
-
|
|
42
|
-
# ── Agent ──
|
|
43
|
-
AGENT_ENABLED=true
|
|
44
|
-
AGENT_MODEL=google/gemini-2.5-flash-lite
|
|
45
|
-
# AGENT_FALLBACK_MODELS=google/gemini-2.5-flash,anthropic/claude-3.5-haiku
|
|
46
|
-
AGENT_MAX_TOKENS=300
|
|
47
|
-
AGENT_TEMPERATURE=0.3
|
|
48
|
-
AGENT_PUSH_TO_FEED=true
|
|
49
|
-
AGENT_DEBOUNCE_MS=3000
|
|
50
|
-
AGENT_MAX_INTERVAL_MS=30000
|
|
51
|
-
AGENT_COOLDOWN_MS=10000
|
|
52
|
-
AGENT_MAX_AGE_MS=120000 # context window lookback (2 min)
|
|
53
|
-
|
|
54
|
-
# ── Escalation ──
|
|
55
|
-
ESCALATION_MODE=selective # off | selective | focus | rich
|
|
56
|
-
ESCALATION_COOLDOWN_MS=30000
|
|
57
|
-
# ESCALATION_TRANSPORT=auto # ws | http | auto — use http for bare agent (no gateway)
|
|
58
|
-
# auto = WS when gateway connected, HTTP fallback
|
|
59
|
-
# http = skip gateway entirely, poll via GET /escalation/pending
|
|
60
|
-
# See docs/INSTALL-BARE-AGENT.md for bare agent setup
|
|
61
|
-
|
|
62
|
-
# ── OpenClaw / NemoClaw Gateway ─────────────────────────────────────────────
|
|
63
|
-
# Run ./setup-nemoclaw.sh to fill these in interactively (recommended).
|
|
64
|
-
#
|
|
65
|
-
# NemoClaw (NVIDIA Brev) quick-start:
|
|
66
|
-
# 1. In Brev dashboard: Expose Port(s) → enter 18789 → TCP → note the IP
|
|
67
|
-
# 2. In Code-Server terminal: npx sinain (installs plugin, prints token)
|
|
68
|
-
# 3. On Mac: ./setup-nemoclaw.sh (interactive wizard)
|
|
69
|
-
#
|
|
70
|
-
# URL: ws://YOUR-IP:18789 (use the IP shown after exposing port 18789)
|
|
71
|
-
# Token: printed by `npx sinain` / visible in Brev dashboard → Gateway Token
|
|
72
|
-
OPENCLAW_WS_URL=ws://localhost:18789
|
|
73
|
-
OPENCLAW_WS_TOKEN= # 48-char hex — from gateway config or `npx sinain` output
|
|
74
|
-
OPENCLAW_HTTP_URL=http://localhost:18789/hooks/agent
|
|
75
|
-
OPENCLAW_HTTP_TOKEN= # same token as WS_TOKEN
|
|
76
|
-
OPENCLAW_SESSION_KEY=agent:main:sinain # MUST be agent:main:sinain — see README § Session Key
|
|
77
|
-
# OPENCLAW_PHASE1_TIMEOUT_MS=10000 # Phase 1 (delivery) timeout — circuit trips on failure
|
|
78
|
-
# OPENCLAW_PHASE2_TIMEOUT_MS=120000 # Phase 2 (agent response) timeout — no circuit trip
|
|
79
|
-
# OPENCLAW_QUEUE_TTL_MS=300000 # Outbound queue message TTL (5 min)
|
|
80
|
-
# OPENCLAW_QUEUE_MAX_SIZE=10 # Max queued escalations (oldest dropped on overflow)
|
|
81
|
-
# OPENCLAW_PING_INTERVAL_MS=30000 # WS ping keepalive interval
|
|
82
|
-
|
|
83
|
-
# ── SITUATION.md ──
|
|
84
|
-
SITUATION_MD_PATH=~/.openclaw/workspace/SITUATION.md
|
|
85
|
-
# OPENCLAW_WORKSPACE_DIR=~/.openclaw/workspace
|
|
86
|
-
|
|
87
|
-
# ── Debug ──
|
|
88
|
-
# DEBUG=true # verbose logging (every tick, every chunk)
|
|
89
|
-
|
|
90
|
-
# ── Tracing ──
|
|
91
|
-
TRACE_ENABLED=true
|
|
92
|
-
TRACE_DIR=~/.sinain-core/traces
|