@geravant/sinain 1.15.5 → 1.18.1

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 CHANGED
@@ -4,7 +4,6 @@ import net from "net";
4
4
  import os from "os";
5
5
  import fs from "fs";
6
6
  import path from "path";
7
- import { writeAgentsConfig } from "./config-shared.js";
8
7
  import { checkForUpdate } from "./self-update.js";
9
8
 
10
9
  const cmd = process.argv[2];
@@ -95,176 +94,6 @@ switch (cmd) {
95
94
  break;
96
95
  }
97
96
 
98
- // ── Setup wizard (standalone) ─────────────────────────────────────────────────
99
-
100
- async function runSetupWizard() {
101
- // Force-run the wizard even if .env exists (re-configure)
102
- const { setupWizard } = await import("./launcher.js?setup-only");
103
- // The wizard is embedded in launcher.js; we import the module dynamically.
104
- // Since launcher.js runs main() on import, we instead inline a lightweight version.
105
-
106
- const readline = await import("readline");
107
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
108
- const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
109
-
110
- const HOME = os.homedir();
111
- const SINAIN_DIR = path.join(HOME, ".sinain");
112
- const envPath = path.join(SINAIN_DIR, ".env");
113
-
114
- const BOLD = "\x1b[1m";
115
- const DIM = "\x1b[2m";
116
- const GREEN = "\x1b[32m";
117
- const YELLOW = "\x1b[33m";
118
- const RESET = "\x1b[0m";
119
- const IS_WIN = os.platform() === "win32";
120
-
121
- const cmdExists = (cmd) => {
122
- try { import("child_process").then(cp => cp.execSync(`which ${cmd}`, { stdio: "pipe" })); return true; }
123
- catch { return false; }
124
- };
125
- // Synchronous version
126
- const { execSync } = await import("child_process");
127
- const cmdExistsSync = (cmd) => {
128
- try { execSync(`which ${cmd}`, { stdio: "pipe" }); return true; }
129
- catch { return false; }
130
- };
131
-
132
- if (fs.existsSync(envPath)) {
133
- const overwrite = await ask(` ${envPath} already exists. Overwrite? [y/N]: `);
134
- if (overwrite.trim().toLowerCase() !== "y") {
135
- console.log(" Aborted.");
136
- rl.close();
137
- return;
138
- }
139
- }
140
-
141
- console.log();
142
- console.log(`${BOLD}── Sinain Setup Wizard ─────────────────${RESET}`);
143
- console.log(` Configuring ${DIM}~/.sinain/.env${RESET}`);
144
- console.log();
145
-
146
- const vars = {};
147
-
148
- // Transcription backend
149
- let transcriptionBackend = "openrouter";
150
- const hasWhisper = !IS_WIN && cmdExistsSync("whisper-cli");
151
-
152
- if (IS_WIN) {
153
- console.log(` ${DIM}(Local whisper not available on Windows — using OpenRouter)${RESET}`);
154
- } else if (hasWhisper) {
155
- const choice = await ask(` Transcription backend? [${BOLD}local${RESET}/cloud]: `);
156
- transcriptionBackend = choice.trim().toLowerCase() === "cloud" ? "openrouter" : "local";
157
- } else {
158
- const install = await ask(` whisper-cli not found. Install via Homebrew? [Y/n]: `);
159
- if (!install.trim() || install.trim().toLowerCase() === "y") {
160
- try {
161
- execSync("brew install whisper-cpp", { stdio: "inherit" });
162
- const modelDir = path.join(HOME, "models");
163
- const modelPath = path.join(modelDir, "ggml-large-v3-turbo.bin");
164
- if (!fs.existsSync(modelPath)) {
165
- console.log(` ${DIM}Downloading model (~1.5 GB)...${RESET}`);
166
- fs.mkdirSync(modelDir, { recursive: true });
167
- execSync(`curl -L --progress-bar -o "${modelPath}" "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin"`, { stdio: "inherit" });
168
- }
169
- transcriptionBackend = "local";
170
- vars.LOCAL_WHISPER_MODEL = modelPath;
171
- } catch {
172
- console.log(` ${YELLOW}Install failed — falling back to OpenRouter${RESET}`);
173
- }
174
- }
175
- }
176
- vars.TRANSCRIPTION_BACKEND = transcriptionBackend;
177
-
178
- // API key
179
- if (transcriptionBackend === "openrouter") {
180
- const key = await ask(` OpenRouter API key (sk-or-...): `);
181
- if (key.trim()) vars.OPENROUTER_API_KEY = key.trim();
182
- } else {
183
- const key = await ask(` OpenRouter API key for vision/OCR (optional): `);
184
- if (key.trim()) vars.OPENROUTER_API_KEY = key.trim();
185
- }
186
-
187
- // Agent + escalation + gateway → agents.json (not .env). Tokens stay in
188
- // .env as secrets, referenced from agents.json via ${VAR} indirection.
189
- const agentsPatch = {};
190
-
191
- // Default agent — overlay's chip selector lets the user switch at runtime;
192
- // this just sets the boot-time default.
193
- const agent = await ask(` Default agent? [${BOLD}claude${RESET}/openclaude/codex/goose/junie/aider]: `);
194
- agentsPatch.default = agent.trim().toLowerCase() || "claude";
195
-
196
- // Escalation mode
197
- console.log(`\n ${DIM}Escalation: off | selective | focus | rich${RESET}`);
198
- const esc = await ask(` Escalation mode? [${BOLD}selective${RESET}]: `);
199
- agentsPatch.escalationMode = esc.trim().toLowerCase() || "selective";
200
-
201
- // Gateway — when enabled, writes the openclaw profile to agents.json
202
- // (URLs + session) and tokens to .env. There's no transport choice
203
- // anymore; picking openclaw vs a local agent in the overlay determines
204
- // dispatch (WS vs HTTP).
205
- const gw = await ask(` OpenClaw gateway? [y/N]: `);
206
- if (gw.trim().toLowerCase() === "y") {
207
- const url = await ask(` Gateway WS URL [ws://localhost:18789]: `);
208
- const wsUrl = url.trim() || "ws://localhost:18789";
209
- const token = await ask(` Auth token (48-char hex): `);
210
- if (token.trim()) {
211
- vars.OPENCLAW_WS_TOKEN = token.trim();
212
- vars.OPENCLAW_HTTP_TOKEN = token.trim();
213
- }
214
- agentsPatch.openclawProfile = {
215
- wsUrl,
216
- httpUrl: wsUrl.replace(/^ws/, "http") + "/hooks/agent",
217
- wsToken: "${OPENCLAW_WS_TOKEN}",
218
- httpToken: "${OPENCLAW_HTTP_TOKEN}",
219
- sessionKey: "agent:main:sinain",
220
- };
221
- } else {
222
- // Skip / disable: drop the openclaw profile entirely.
223
- agentsPatch.openclawProfile = null;
224
- agentsPatch.escalationMode = "off";
225
- }
226
-
227
- vars.SINAIN_HEARTBEAT_INTERVAL = "900";
228
- vars.PRIVACY_MODE = "standard";
229
-
230
- // Write — start from .env.example template, patch wizard values in
231
- fs.mkdirSync(SINAIN_DIR, { recursive: true });
232
-
233
- const PKG_DIR = path.dirname(new URL(import.meta.url).pathname);
234
- const examplePath = path.join(PKG_DIR, ".env.example");
235
- const siblingExample = path.join(PKG_DIR, "..", ".env.example");
236
- let template = "";
237
- if (fs.existsSync(examplePath)) {
238
- template = fs.readFileSync(examplePath, "utf-8");
239
- } else if (fs.existsSync(siblingExample)) {
240
- template = fs.readFileSync(siblingExample, "utf-8");
241
- }
242
-
243
- if (template) {
244
- for (const [k, v] of Object.entries(vars)) {
245
- const regex = new RegExp(`^#?\\s*${k}=.*$`, "m");
246
- if (regex.test(template)) {
247
- template = template.replace(regex, `${k}=${v}`);
248
- } else {
249
- template += `\n${k}=${v}`;
250
- }
251
- }
252
- template = `# Generated by sinain setup wizard — ${new Date().toISOString()}\n${template}`;
253
- fs.writeFileSync(envPath, template);
254
- } else {
255
- const lines = ["# sinain configuration — generated by setup wizard", `# ${new Date().toISOString()}`, ""];
256
- for (const [k, v] of Object.entries(vars)) lines.push(`${k}=${v}`);
257
- lines.push("");
258
- fs.writeFileSync(envPath, lines.join("\n"));
259
- }
260
-
261
- // Patch ~/.sinain/agents.json with the wizard's agent + gateway choices.
262
- writeAgentsConfig(agentsPatch);
263
-
264
- rl.close();
265
- console.log(`\n ${GREEN}✓${RESET} Config written to ${envPath} + ~/.sinain/agents.json\n`);
266
- }
267
-
268
97
  // ── Stop ──────────────────────────────────────────────────────────────────────
269
98
 
270
99
  async function stopServices() {
package/launcher.js CHANGED
@@ -8,7 +8,6 @@ import path from "path";
8
8
  import os from "os";
9
9
  import net from "net";
10
10
  import readline from "readline";
11
- import { writeAgentsConfig, readAgentsConfig } from "./config-shared.js";
12
11
 
13
12
  // ── Colors ──────────────────────────────────────────────────────────────────
14
13
 
@@ -417,303 +416,6 @@ async function ensureOllama() {
417
416
  }
418
417
  }
419
418
 
420
- // ── Setup wizard ─────────────────────────────────────────────────────────────
421
-
422
- async function setupWizard(envPath) {
423
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
424
- const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
425
-
426
- // Load existing .env values as defaults (for re-configuration)
427
- const existing = {};
428
- if (fs.existsSync(envPath)) {
429
- for (const line of fs.readFileSync(envPath, "utf-8").split("\n")) {
430
- const m = line.match(/^([A-Z_]+)=(.*)$/);
431
- if (m) existing[m[1]] = m[2];
432
- }
433
- }
434
- const hasExisting = Object.keys(existing).length > 0;
435
-
436
- console.log();
437
- console.log(`${BOLD}── ${hasExisting ? "Re-configure" : "First-time setup"} ────────────────────${RESET}`);
438
- console.log(` Configuring ${DIM}~/.sinain/.env${RESET}`);
439
- if (hasExisting) console.log(` ${DIM}Press Enter to keep current values shown in [brackets]${RESET}`);
440
- console.log();
441
-
442
- const vars = {};
443
-
444
- // 1. Transcription backend — auto-detect whisper-cli
445
- let transcriptionBackend = "openrouter";
446
- const hasWhisper = !IS_WINDOWS && commandExists("whisper-cli");
447
-
448
- if (IS_WINDOWS) {
449
- console.log(` ${DIM}(Local whisper not available on Windows — using OpenRouter)${RESET}`);
450
- } else if (hasWhisper) {
451
- const choice = await ask(` Transcription backend? [${BOLD}local${RESET}/cloud] (local = whisper-cli, no API key): `);
452
- if (choice.trim().toLowerCase() === "cloud") {
453
- transcriptionBackend = "openrouter";
454
- } else {
455
- transcriptionBackend = "local";
456
- }
457
- } else {
458
- const installWhisper = await ask(` whisper-cli not found. Install via Homebrew? [Y/n]: `);
459
- if (!installWhisper.trim() || installWhisper.trim().toLowerCase() === "y") {
460
- try {
461
- console.log(` ${DIM}Installing whisper-cpp...${RESET}`);
462
- execSync("brew install whisper-cpp", { stdio: "inherit" });
463
-
464
- // Download model
465
- const modelDir = path.join(HOME, "models");
466
- const modelPath = path.join(modelDir, "ggml-large-v3-turbo.bin");
467
- if (!fs.existsSync(modelPath)) {
468
- console.log(` ${DIM}Downloading ggml-large-v3-turbo (~1.5 GB)...${RESET}`);
469
- fs.mkdirSync(modelDir, { recursive: true });
470
- execSync(
471
- `curl -L --progress-bar -o "${modelPath}" "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin"`,
472
- { stdio: "inherit" }
473
- );
474
- }
475
-
476
- transcriptionBackend = "local";
477
- vars.LOCAL_WHISPER_MODEL = modelPath;
478
- ok("whisper-cpp installed");
479
- } catch {
480
- warn("whisper-cpp install failed — falling back to OpenRouter");
481
- transcriptionBackend = "openrouter";
482
- }
483
- } else {
484
- transcriptionBackend = "openrouter";
485
- }
486
- }
487
- vars.TRANSCRIPTION_BACKEND = transcriptionBackend;
488
-
489
- // 2. OpenRouter API key (if cloud backend or for vision/OCR)
490
- if (transcriptionBackend === "openrouter") {
491
- const existingKey = existing.OPENROUTER_API_KEY;
492
- const keyHint = existingKey ? ` [${existingKey.slice(0, 8)}...${existingKey.slice(-4)}]` : "";
493
- let key = "";
494
- while (!key) {
495
- key = await ask(` OpenRouter API key (sk-or-...)${keyHint}: `);
496
- key = key.trim();
497
- if (!key && existingKey) { key = existingKey; break; }
498
- if (key && !key.startsWith("sk-or-")) {
499
- console.log(` ${YELLOW}⚠${RESET} Key should start with sk-or-. Try again or press Enter to skip.`);
500
- const retry = await ask(` Use this key anyway? [y/N]: `);
501
- if (retry.trim().toLowerCase() !== "y") { key = ""; continue; }
502
- }
503
- if (!key) {
504
- console.log(` ${DIM}You can set OPENROUTER_API_KEY later in ~/.sinain/.env${RESET}`);
505
- break;
506
- }
507
- }
508
- if (key) vars.OPENROUTER_API_KEY = key;
509
- } else {
510
- // Still ask for OpenRouter key (needed for vision/OCR)
511
- const existingKey = existing.OPENROUTER_API_KEY;
512
- const keyHint = existingKey ? ` [${existingKey.slice(0, 8)}...${existingKey.slice(-4)}]` : "";
513
- const key = await ask(` OpenRouter API key for vision/OCR (optional, Enter to skip)${keyHint}: `);
514
- if (key.trim()) vars.OPENROUTER_API_KEY = key.trim();
515
- else if (existingKey) vars.OPENROUTER_API_KEY = existingKey;
516
- }
517
-
518
- // 3. Agent selection — written to agents.json `default` field. Existing
519
- // value is read from agents.json first, falling back to .env for users
520
- // mid-migration.
521
- const existingAgentsCfg = readAgentsConfig();
522
- const agentsPatch = {};
523
- const defaultAgent = existingAgentsCfg?.default || existing.SINAIN_AGENT || "claude";
524
- const agentChoice = await ask(` Default agent? [${BOLD}${defaultAgent}${RESET}/claude/openclaude/codex/goose/junie/aider]: `);
525
- agentsPatch.default = agentChoice.trim().toLowerCase() || defaultAgent;
526
-
527
- // 3b. Local vision (Ollama)
528
- const IS_MACOS = os.platform() === "darwin";
529
- const hasOllama = commandExists("ollama");
530
- if (hasOllama) {
531
- const useVision = await ask(` Enable local vision AI? [Y/n] (Ollama — screen understanding without cloud API): `);
532
- if (!useVision.trim() || useVision.trim().toLowerCase() === "y") {
533
- vars.LOCAL_VISION_ENABLED = "true";
534
- // Ensure ollama serve is running before list/pull
535
- const ollamaReady = await ensureOllama();
536
- if (ollamaReady) {
537
- try {
538
- const models = execSync("ollama list 2>/dev/null", { encoding: "utf-8" });
539
- if (!models.includes("llava")) {
540
- const pull = await ask(` Pull llava vision model (~4GB)? [Y/n]: `);
541
- if (!pull.trim() || pull.trim().toLowerCase() === "y") {
542
- console.log(` ${DIM}Pulling llava...${RESET}`);
543
- execSync("ollama pull llava", { stdio: "inherit" });
544
- ok("llava model pulled");
545
- }
546
- } else {
547
- ok("llava model already available");
548
- }
549
- } catch {
550
- warn("Could not check Ollama models");
551
- }
552
- }
553
- vars.LOCAL_VISION_MODEL = "llava";
554
- }
555
- } else {
556
- const installOllama = await ask(` Install Ollama for local vision AI? [y/N]: `);
557
- if (installOllama.trim().toLowerCase() === "y") {
558
- try {
559
- if (IS_MACOS) {
560
- console.log(` ${DIM}Installing Ollama via Homebrew...${RESET}`);
561
- execSync("brew install ollama", { stdio: "inherit" });
562
- } else {
563
- console.log(` ${DIM}Installing Ollama...${RESET}`);
564
- execSync("curl -fsSL https://ollama.com/install.sh | sh", { stdio: "inherit" });
565
- }
566
- // Start ollama serve before pulling
567
- await ensureOllama();
568
- console.log(` ${DIM}Pulling llava vision model...${RESET}`);
569
- execSync("ollama pull llava", { stdio: "inherit" });
570
- vars.LOCAL_VISION_ENABLED = "true";
571
- vars.LOCAL_VISION_MODEL = "llava";
572
- ok("Ollama + llava installed");
573
- } catch {
574
- warn("Ollama installation failed — local vision disabled");
575
- }
576
- }
577
- }
578
-
579
- // 4. Escalation mode — agents.json `escalation.mode`
580
- console.log();
581
- console.log(` ${DIM}Escalation modes:${RESET}`);
582
- console.log(` off — no escalation`);
583
- console.log(` selective — score-based (errors, questions trigger it)`);
584
- console.log(` focus — always escalate every tick`);
585
- console.log(` rich — always escalate with maximum context`);
586
- const defaultEsc = existingAgentsCfg?.escalation?.mode || existing.ESCALATION_MODE || "selective";
587
- const escMode = await ask(` Escalation mode? [off/${BOLD}${defaultEsc}${RESET}/selective/focus/rich]: `);
588
- agentsPatch.escalationMode = escMode.trim().toLowerCase() || defaultEsc;
589
-
590
- // 5. OpenClaw gateway — URLs/session → agents.json (openclaw profile),
591
- // tokens → .env. No transport question; agent identity (openclaw vs
592
- // local) determines dispatch path.
593
- const existingOpenclaw = existingAgentsCfg?.profiles?.openclaw;
594
- const existingWsUrl = existingOpenclaw?.wsUrl || existing.OPENCLAW_WS_URL || "";
595
- const hadGateway = !!existingWsUrl;
596
- const gatewayDefault = hadGateway ? "Y" : "N";
597
- const hasGateway = await ask(` Do you have an OpenClaw gateway? [${gatewayDefault === "Y" ? "Y/n" : "y/N"}]: `);
598
- const wantsGateway = hasGateway.trim()
599
- ? hasGateway.trim().toLowerCase() === "y"
600
- : hadGateway;
601
- if (wantsGateway) {
602
- const defaultWs = existingWsUrl || "ws://localhost:18789";
603
- const wsUrl = await ask(` Gateway WebSocket URL [${defaultWs}]: `);
604
- const finalWsUrl = wsUrl.trim() || defaultWs;
605
-
606
- const existingToken = existing.OPENCLAW_WS_TOKEN;
607
- const tokenHint = existingToken ? ` [${existingToken.slice(0, 6)}...${existingToken.slice(-4)}]` : "";
608
- const wsToken = await ask(` Gateway auth token (48-char hex)${tokenHint}: `);
609
- if (wsToken.trim()) {
610
- vars.OPENCLAW_WS_TOKEN = wsToken.trim();
611
- vars.OPENCLAW_HTTP_TOKEN = wsToken.trim();
612
- } else if (existingToken) {
613
- vars.OPENCLAW_WS_TOKEN = existingToken;
614
- vars.OPENCLAW_HTTP_TOKEN = existing.OPENCLAW_HTTP_TOKEN || existingToken;
615
- }
616
-
617
- agentsPatch.openclawProfile = {
618
- wsUrl: finalWsUrl,
619
- httpUrl: finalWsUrl.replace(/^ws/, "http") + "/hooks/agent",
620
- wsToken: "${OPENCLAW_WS_TOKEN}",
621
- httpToken: "${OPENCLAW_HTTP_TOKEN}",
622
- sessionKey: existingOpenclaw?.sessionKey || "agent:main:sinain",
623
- };
624
- } else {
625
- // No gateway — drop the openclaw profile entirely.
626
- agentsPatch.openclawProfile = null;
627
- }
628
-
629
- // 6. Knowledge import (for standalone machines)
630
- console.log();
631
- const wantImport = await ask(` Import knowledge from another machine? [y/N]: `);
632
- if (wantImport.trim().toLowerCase() === "y") {
633
- const filePath = await ask(` Path to knowledge export (.tar.gz): `);
634
- const resolved = filePath.trim().replace(/^~/, HOME);
635
- if (resolved && fs.existsSync(resolved)) {
636
- const targetWorkspace = path.join(HOME, ".sinain/workspace");
637
- fs.mkdirSync(targetWorkspace, { recursive: true });
638
- try {
639
- execSync(`tar xzf "${resolved}" -C "${targetWorkspace}"`, { stdio: "inherit" });
640
- // Symlink sinain-memory scripts from npm package
641
- const srcMemory = path.join(PKG_DIR, "sinain-memory");
642
- const dstMemory = path.join(targetWorkspace, "sinain-memory");
643
- try { fs.rmSync(dstMemory, { recursive: true }); } catch {}
644
- fs.symlinkSync(srcMemory, dstMemory);
645
- vars.SINAIN_WORKSPACE = targetWorkspace;
646
- vars.OPENCLAW_WORKSPACE_DIR = targetWorkspace;
647
- ok(`Knowledge imported to ${targetWorkspace}`);
648
- } catch (e) {
649
- warn(`Import failed: ${e.message}`);
650
- }
651
- } else if (resolved) {
652
- warn(`File not found: ${resolved}`);
653
- }
654
- }
655
-
656
- // 7. Agent-specific defaults
657
- vars.SINAIN_POLL_INTERVAL = "5";
658
- vars.SINAIN_HEARTBEAT_INTERVAL = "900";
659
- vars.PRIVACY_MODE = "standard";
660
-
661
- // Write .env — start from .env.example template, patch wizard values in
662
- fs.mkdirSync(path.dirname(envPath), { recursive: true });
663
-
664
- const examplePath = path.join(PKG_DIR, ".env.example");
665
- let template = "";
666
- if (fs.existsSync(examplePath)) {
667
- template = fs.readFileSync(examplePath, "utf-8");
668
- } else {
669
- // Fallback: try sibling (running from cloned repo)
670
- const siblingExample = path.join(PKG_DIR, "..", ".env.example");
671
- if (fs.existsSync(siblingExample)) {
672
- template = fs.readFileSync(siblingExample, "utf-8");
673
- }
674
- }
675
-
676
- if (template) {
677
- // Patch each wizard var into the template by replacing the KEY=... line
678
- for (const [key, val] of Object.entries(vars)) {
679
- // Match KEY=anything (possibly commented out with #)
680
- const regex = new RegExp(`^#?\\s*${key}=.*$`, "m");
681
- if (regex.test(template)) {
682
- template = template.replace(regex, `${key}=${val}`);
683
- } else {
684
- // Key not in template — append it
685
- template += `\n${key}=${val}`;
686
- }
687
- }
688
- // Add wizard timestamp header
689
- template = `# Generated by sinain setup wizard — ${new Date().toISOString()}\n${template}`;
690
- fs.writeFileSync(envPath, template);
691
- } else {
692
- // No template found — write bare vars (fallback)
693
- const lines = [];
694
- lines.push("# sinain configuration — generated by setup wizard");
695
- lines.push(`# ${new Date().toISOString()}`);
696
- lines.push("");
697
- for (const [key, val] of Object.entries(vars)) {
698
- lines.push(`${key}=${val}`);
699
- }
700
- lines.push("");
701
- fs.writeFileSync(envPath, lines.join("\n"));
702
- }
703
-
704
- // Flush agent + gateway answers to ~/.sinain/agents.json (separate file
705
- // from .env after the profile-config refactor).
706
- if (Object.keys(agentsPatch).length > 0) {
707
- writeAgentsConfig(agentsPatch);
708
- }
709
-
710
- rl.close();
711
-
712
- console.log();
713
- ok(`Config written to ${envPath} + ~/.sinain/agents.json`);
714
- console.log();
715
- }
716
-
717
419
  // ── User environment ────────────────────────────────────────────────────────
718
420
 
719
421
  function loadUserEnv() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geravant/sinain",
3
- "version": "1.15.5",
3
+ "version": "1.18.1",
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": {
@@ -43,6 +43,9 @@
43
43
  "sinain-agent/agents.example.json",
44
44
  "sinain-agent/.env.example",
45
45
  "sinain-agent/CLAUDE.md",
46
+ "sinain-agent/openrouter-proxy.mjs",
47
+ "sinain-agent/.claude/settings.json",
48
+ "sinain-agent/hooks/approve-tool.sh",
46
49
  "sense_client",
47
50
  "HEARTBEAT.md",
48
51
  "SKILL.md"
@@ -0,0 +1,16 @@
1
+ {
2
+ "hooks": {
3
+ "PreToolUse": [
4
+ {
5
+ "matcher": "*",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "./hooks/approve-tool.sh",
10
+ "timeout": 130
11
+ }
12
+ ]
13
+ }
14
+ ]
15
+ }
16
+ }
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env bash
2
+ # PreToolUse hook for sinain-agent (escalation + spawn paths).
3
+ #
4
+ # Forwards every tool-invocation to sinain-core /spawn/approve which:
5
+ # - auto-approves safe read-only tools (Read, Glob, Grep, Ls, Cat)
6
+ # - auto-approves all mcp__sinain* tools
7
+ # - routes everything else to the overlay for Allow/Deny
8
+ #
9
+ # Scoped to sinain-agent via --settings in run.sh: regular openclaude/claude
10
+ # sessions outside this directory don't load this settings.json and aren't
11
+ # affected. Previously this hook early-exited unless SINAIN_SPAWN=1 was set,
12
+ # which broke escalation-path write permissions (agent couldn't Bash/Edit).
13
+
14
+ CORE_URL="${SINAIN_CORE_URL:-http://localhost:9500}"
15
+
16
+ # Read hook input from stdin. Claude Code / openclaude typically include
17
+ # session_id per the PreToolUse contract; if missing (or if we want a sinain-
18
+ # native correlation), inject SINAIN_SPAWN_TASK_ID / SINAIN_ESC_TASK_ID as
19
+ # sinainTaskId so the server can still key YOLO on a stable id per invocation.
20
+ HOOK_STDIN=$(cat)
21
+ SINAIN_TASK_ID="${SINAIN_SPAWN_TASK_ID:-${SINAIN_ESC_TASK_ID:-}}"
22
+ if [ -n "$SINAIN_TASK_ID" ] && command -v python3 >/dev/null 2>&1; then
23
+ HOOK_STDIN=$(printf '%s' "$HOOK_STDIN" | SINAIN_TASK_ID="$SINAIN_TASK_ID" python3 -c '
24
+ import json, os, sys
25
+ try:
26
+ d = json.load(sys.stdin)
27
+ if isinstance(d, dict) and not d.get("sinainTaskId"):
28
+ d["sinainTaskId"] = os.environ["SINAIN_TASK_ID"]
29
+ print(json.dumps(d))
30
+ except Exception:
31
+ # On any parse failure, pass original through — server can still work
32
+ sys.stdout.write(os.environ.get("HOOK_STDIN_FALLBACK", ""))
33
+ ')
34
+ fi
35
+
36
+ RESPONSE=$(printf '%s' "$HOOK_STDIN" | curl -sf -X POST "$CORE_URL/spawn/approve" \
37
+ -H 'Content-Type: application/json' \
38
+ --max-time 130 \
39
+ --data-binary @- 2>/dev/null)
40
+
41
+ if [ -n "$RESPONSE" ]; then
42
+ echo "$RESPONSE"
43
+ else
44
+ # If sinain-core is unreachable, allow by default (don't block the agent)
45
+ echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"sinain-core unreachable, auto-allowing"}}'
46
+ fi