@geravant/sinain 1.25.0 → 1.26.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 CHANGED
@@ -16,6 +16,17 @@ ANALYSIS_MODEL=google/gemini-2.5-flash-lite
16
16
  OPENROUTER_API_KEY= # get one free at https://openrouter.ai
17
17
  # used by context analysis + transcription
18
18
 
19
+ # ── Local Mode (unified — fully offline, zero cloud) ─────────────────────────
20
+ # SINAIN_LOCAL_* is the primary namespace. Setting SINAIN_LOCAL_MODE=true
21
+ # auto-derives analyzer, vision, and transcription config from the two model
22
+ # vars below (see sinain-core/src/config.ts). The legacy LOCAL_VISION_* vars
23
+ # are still honored as a fallback but are deprecated — prefer SINAIN_LOCAL_*.
24
+ # Quick start: cp .env.paranoid .env (or ./start.sh --paranoid)
25
+ # Prereqs: ollama serve && ollama pull phi4-mini qwen2.5vl:7b
26
+ # SINAIN_LOCAL_MODE=false # true → offline analyzer + vision + STT
27
+ # SINAIN_LOCAL_LLM=phi4-mini # Ollama model: analyzer + distiller
28
+ # SINAIN_LOCAL_VISION=qwen2.5vl:7b # Ollama model: screen OCR / scene (sense_client)
29
+
19
30
  # ── Privacy ──────────────────────────────────────────────────────────────────
20
31
  PRIVACY_MODE=standard # off | standard | strict | paranoid
21
32
  # standard: auto-redacts credentials before cloud APIs
@@ -86,7 +97,7 @@ TRANSCRIPTION_LANGUAGE=en-US
86
97
  # Install: brew install whisper-cpp
87
98
  # Models: https://huggingface.co/ggerganov/whisper.cpp/tree/main
88
99
  # LOCAL_WHISPER_BIN=whisper-cli
89
- # LOCAL_WHISPER_MODEL=~/models/ggml-large-v3-turbo.bin
100
+ # LOCAL_WHISPER_MODEL=~/.sinain/models/whisper/ggml-large-v3-turbo.bin
90
101
  # LOCAL_WHISPER_TIMEOUT_MS=15000
91
102
 
92
103
  # ── OpenClaw / NemoClaw Gateway ──────────────────────────────────────────────
package/launcher.js CHANGED
@@ -413,8 +413,7 @@ async function preflight() {
413
413
  if (fs.existsSync(prebuiltApp)) {
414
414
  ok("overlay: pre-built app");
415
415
  } else {
416
- warn("no overlay available — run: sinain setup-overlay");
417
- skipOverlay = true;
416
+ warn("no overlay available — will auto-download from GitHub Releases");
418
417
  }
419
418
  }
420
419
 
package/onboard.js CHANGED
@@ -68,9 +68,12 @@ async function stepOverlay(existing) {
68
68
  const label = choice === "download" ? "Downloading overlay..." : "Building overlay from source...";
69
69
  s.start(label);
70
70
  try {
71
- // setup-overlay.js handles both modes via process.argv
72
- if (choice === "source") process.argv.push("--from-source");
73
- await import("./setup-overlay.js");
71
+ const { downloadOverlay, buildFromSource } = await import("./setup-overlay.js");
72
+ if (choice === "source") {
73
+ await buildFromSource();
74
+ } else {
75
+ await downloadOverlay({ silent: true });
76
+ }
74
77
  s.stop(c.green("Overlay installed."));
75
78
  } catch (err) {
76
79
  s.stop(c.yellow(`Failed: ${err.message}`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geravant/sinain",
3
- "version": "1.25.0",
3
+ "version": "1.26.0",
4
4
  "description": "Context OS — ambient intelligence for builders. Captures screen + audio, distills into a private knowledge graph, accessible from MCP, web UI, and HUD overlay.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,7 +41,7 @@ DEFAULTS = {
41
41
  "vision": {
42
42
  "enabled": False,
43
43
  "backend": "ollama",
44
- "model": "llava",
44
+ "model": "qwen2.5vl:7b",
45
45
  "ollamaUrl": "http://localhost:11434",
46
46
  "timeout": 10.0,
47
47
  "throttleSeconds": 5,
@@ -1,7 +1,7 @@
1
1
  """Ollama Vision — local multimodal inference for screen scene understanding.
2
2
 
3
- Provides a thin client for Ollama's vision models (llava, llama3.2-vision,
4
- moondream, nanollava). Used by sense_client for scene descriptions and
3
+ Provides a thin client for Ollama's vision models (qwen2.5vl, llama3.2-vision,
4
+ moondream, llava). Used by sense_client for scene descriptions and
5
5
  optionally by sinain-core's agent analyzer for local vision analysis.
6
6
 
7
7
  Falls back gracefully when Ollama is unavailable — never crashes the pipeline.
@@ -37,7 +37,7 @@ class OllamaVision:
37
37
 
38
38
  def __init__(
39
39
  self,
40
- model: str = "llava",
40
+ model: str = "qwen2.5vl:7b",
41
41
  base_url: str = "http://localhost:11434",
42
42
  timeout: float = 30.0,
43
43
  max_tokens: int = 200,
@@ -56,7 +56,7 @@ class VisionProvider(ABC):
56
56
  class OllamaVisionProvider(VisionProvider):
57
57
  """Local vision via Ollama HTTP API."""
58
58
 
59
- def __init__(self, model: str = "llava", base_url: str = "http://localhost:11434",
59
+ def __init__(self, model: str = "qwen2.5vl:7b", base_url: str = "http://localhost:11434",
60
60
  timeout: float = 10.0, max_tokens: int = 200):
61
61
  from .ollama_vision import OllamaVision
62
62
  self._client = OllamaVision(model=model, base_url=base_url,
@@ -172,19 +172,30 @@ def create_vision(config: dict) -> Optional[VisionProvider]:
172
172
 
173
173
  Priority:
174
174
  1. Paranoid privacy or no API key → local only (Ollama)
175
- 2. LOCAL_VISION_ENABLED=true → local (Ollama)
175
+ 2. SINAIN_LOCAL_MODE=true / SINAIN_LOCAL_VISION set → local (Ollama)
176
176
  3. API key available → cloud (OpenRouter)
177
177
  4. Nothing available → None (vision disabled, OCR still works)
178
+
179
+ Env-var namespace: SINAIN_LOCAL_* is primary. The legacy LOCAL_VISION_*
180
+ vars are still honored as a fallback for older .env files; sinain-core's
181
+ config.ts also bridges SINAIN_LOCAL_* → LOCAL_VISION_* for compatibility.
178
182
  """
179
183
  privacy = os.environ.get("PRIVACY_MODE", "off")
180
184
  api_key = os.environ.get("OPENROUTER_API_KEY", "")
181
185
  vision_cfg = config.get("vision", {})
182
186
 
187
+ # Primary: SINAIN_LOCAL_MODE / SINAIN_LOCAL_VISION. Legacy: LOCAL_VISION_*.
183
188
  local_enabled = (
184
189
  vision_cfg.get("enabled", False)
190
+ or os.environ.get("SINAIN_LOCAL_MODE", "").lower() == "true"
191
+ or bool(os.environ.get("SINAIN_LOCAL_VISION", ""))
185
192
  or os.environ.get("LOCAL_VISION_ENABLED", "").lower() == "true"
186
193
  )
187
- local_model = os.environ.get("LOCAL_VISION_MODEL", vision_cfg.get("model", "llava"))
194
+ local_model = (
195
+ os.environ.get("SINAIN_LOCAL_VISION")
196
+ or os.environ.get("LOCAL_VISION_MODEL")
197
+ or vision_cfg.get("model", "qwen2.5vl:7b")
198
+ )
188
199
  local_url = vision_cfg.get("ollamaUrl", "http://localhost:11434")
189
200
  local_timeout = vision_cfg.get("timeout", 10.0)
190
201
 
package/setup-overlay.js CHANGED
@@ -199,7 +199,7 @@ ${GREEN}✓${RESET} Overlay ready!
199
199
 
200
200
  // ── Build from source (legacy) ───────────────────────────────────────────────
201
201
 
202
- async function buildFromSource() {
202
+ export async function buildFromSource() {
203
203
  // Check flutter
204
204
  try {
205
205
  execSync("which flutter", { stdio: "pipe" });
@@ -197,6 +197,11 @@ agent_has_mcp() {
197
197
  type=$(prof_get_or "$check" type "$check")
198
198
  case "$type" in
199
199
  claude|openclaude|codex|goose) return 0 ;;
200
+ # Hermes is an MCP client, but headless tool approval (calling back into
201
+ # sinain_respond) depends on its yolo/approval config. Default to pipe
202
+ # mode (self-contained text oracle); opt in to the claude-style MCP flow
203
+ # with HERMES_USE_MCP=true once approval is configured (see startup block).
204
+ hermes) [ "${HERMES_USE_MCP:-false}" = "true" ] && return 0 || return 1 ;;
200
205
  junie) $JUNIE_HAS_MCP ;;
201
206
  *) return 1 ;;
202
207
  esac
@@ -330,6 +335,16 @@ invoke_agent() {
330
335
  --no-session \
331
336
  --max-turns "$turns"
332
337
  ;;
338
+ hermes)
339
+ # MCP mode (reached only when HERMES_USE_MCP=true — see agent_has_mcp).
340
+ # Hermes loads the sinain MCP server from ~/.hermes/config.yaml
341
+ # (registered at startup) and calls sinain_respond / sinain_knowledge_query
342
+ # itself, mirroring the claude flow. `-z/--oneshot` prints only the final
343
+ # text to stdout and auto-bypasses approvals (no TTY hang). Turn budget
344
+ # comes from config.yaml (max_turns, default 60) — there's no top-level
345
+ # --max-turns flag. `--toolsets`/`-t` can narrow tools if needed.
346
+ "$bin" -z "$prompt"
347
+ ;;
333
348
  aider)
334
349
  return 1 # No MCP support — caller falls back to invoke_pipe
335
350
  ;;
@@ -376,6 +391,17 @@ invoke_pipe() {
376
391
  aider)
377
392
  "$bin" --yes -m "$msg"
378
393
  ;;
394
+ hermes)
395
+ # Hermes one-shot: `-z/--oneshot` sends a single prompt and prints ONLY
396
+ # the final response text to stdout (no banner/spinner/tool previews,
397
+ # no session-id line) — and auto-bypasses tool approvals, so it never
398
+ # hangs waiting on a TTY. Tools, memory, and skills still load. The
399
+ # escalation message already includes full screen/audio/digest context,
400
+ # so Hermes answers as a self-contained oracle using its own configured
401
+ # model (set via `hermes model`/`hermes setup`) — no sinain MCP needed.
402
+ # For the richer flow where Hermes calls sinain tools, set HERMES_USE_MCP=true.
403
+ "$bin" -z "$msg" 2>/dev/null
404
+ ;;
379
405
  *)
380
406
  # Generic: pipe message to stdin to whatever binary the profile names
381
407
  echo "$msg" | "$bin" 2>/dev/null
@@ -455,6 +481,46 @@ print(' sinain extension added to ' + config_path)
455
481
  fi
456
482
  fi
457
483
 
484
+ # Hermes: auto-register sinain MCP server in ~/.hermes/config.yaml (opt-in).
485
+ # Only when HERMES_USE_MCP=true and hermes is the selected agent — pipe mode
486
+ # (the default) is a black-box text oracle and needs none of this. Hermes
487
+ # reads MCP servers from config.yaml under the `mcp_servers` key (stdio:
488
+ # command + args + env). ruamel.yaml (a Hermes core dep) preserves the
489
+ # user's comments/formatting; falls back to PyYAML if unavailable.
490
+ if [ "${HERMES_USE_MCP:-false}" = "true" ] && [ "$AGENT" = "hermes" ]; then
491
+ TSX_BIN="$(cd "$SCRIPT_DIR/.." && pwd)/sinain-core/node_modules/.bin/tsx"
492
+ MCP_ENTRY="$(cd "$SCRIPT_DIR/.." && pwd)/sinain-mcp-server/index.ts"
493
+ HERMES_CONFIG="${HERMES_CONFIG_DIR:-$HOME/.hermes}/config.yaml"
494
+ if [ -f "$HERMES_CONFIG" ] && ! grep -q "sinain:" "$HERMES_CONFIG" 2>/dev/null; then
495
+ echo "Registering sinain MCP server with hermes ($HERMES_CONFIG)..."
496
+ python3 -c "
497
+ import sys
498
+ try:
499
+ from ruamel.yaml import YAML
500
+ _y = YAML()
501
+ load = _y.load
502
+ def dump(cfg, f): _y.dump(cfg, f)
503
+ except Exception:
504
+ import yaml as _py
505
+ load = _py.safe_load
506
+ def dump(cfg, f): _py.safe_dump(cfg, f, default_flow_style=False, sort_keys=False)
507
+ path, tsx, entry, core, ws = sys.argv[1:6]
508
+ with open(path) as f:
509
+ cfg = load(f) or {}
510
+ cfg.setdefault('mcp_servers', {})['sinain'] = {
511
+ 'command': tsx,
512
+ 'args': [entry],
513
+ 'env': {'SINAIN_CORE_URL': core, 'SINAIN_WORKSPACE': ws},
514
+ }
515
+ with open(path, 'w') as f:
516
+ dump(cfg, f)
517
+ print(' sinain mcp_server added to ' + path)
518
+ " "$HERMES_CONFIG" "$TSX_BIN" "$MCP_ENTRY" "$CORE_URL" "$WORKSPACE"
519
+ elif [ ! -f "$HERMES_CONFIG" ]; then
520
+ echo " ⚠ HERMES_USE_MCP=true but $HERMES_CONFIG missing — run \`hermes setup\` first"
521
+ fi
522
+ fi
523
+
458
524
  # Ollama warmup — pin the backing model so each agent invocation hits hot weights.
459
525
  # openclaude + Ollama via the OpenAI-compat endpoint does NOT forward keep_alive,
460
526
  # so we ping Ollama's native /api/generate once with keep_alive=-1 (persistent).
@@ -498,7 +564,7 @@ fi
498
564
  # Built-in defaults are 1:1 (profile name == binary == type). Users can
499
565
  # override fields or add custom profiles by editing sinain-agent/agents.json.
500
566
  # Profiles whose binaries aren't in PATH are silently skipped.
501
- for default_name in claude openclaude codex goose junie aider; do
567
+ for default_name in claude openclaude codex goose junie aider hermes; do
502
568
  prof_set "$default_name" bin "$default_name"
503
569
  prof_set "$default_name" type "$default_name"
504
570
  done
@@ -212,7 +212,7 @@ export function loadConfig(): CoreConfig {
212
212
  language: env("TRANSCRIPTION_LANGUAGE", "en-US"),
213
213
  local: {
214
214
  bin: env("LOCAL_WHISPER_BIN", "whisper-cli"),
215
- modelPath: resolvePath(env("LOCAL_WHISPER_MODEL", "~/models/ggml-large-v3-turbo.bin")),
215
+ modelPath: resolvePath(env("LOCAL_WHISPER_MODEL", "~/.sinain/models/whisper/ggml-large-v3-turbo.bin")),
216
216
  language: env("TRANSCRIPTION_LANGUAGE", "en-US"),
217
217
  timeoutMs: intEnv("LOCAL_WHISPER_TIMEOUT_MS", 15000),
218
218
  },
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Download Manager — resumable, integrity-checked, atomic model downloads.
3
+ *
4
+ * SEED-001 Phase 4. Drives model-weight downloads into ~/.sinain/models/ for
5
+ * the first-run wizard (whisper model for T1/T2). Ollama models are pulled via
6
+ * Ollama's own /api/pull, NOT this manager.
7
+ *
8
+ * STATUS: SCAFFOLD. The download/verify/atomic-install core below is a working
9
+ * first implementation, but the manifest fetch + wizard wiring are marked TODO
10
+ * and it is not yet called from anywhere. See docs/dmg-distribution-spec.md §5.
11
+ *
12
+ * Design (SPEC §5a):
13
+ * - Resumable: HTTP Range requests; persists a `.part` file + byte offset.
14
+ * - Integrity: SHA-256 verified against the hosted manifest before promotion.
15
+ * - Atomic: download to `*.part` → verify → rename() into the final path,
16
+ * so the canonical path never holds a half-written model.
17
+ */
18
+
19
+ import { createHash } from "node:crypto";
20
+ import { createReadStream, createWriteStream } from "node:fs";
21
+ import { mkdir, rename, stat } from "node:fs/promises";
22
+ import { dirname } from "node:path";
23
+ import { Readable } from "node:stream";
24
+ import { pipeline } from "node:stream/promises";
25
+ import { log, warn } from "../log.js";
26
+
27
+ const TAG = "download";
28
+
29
+ /** One downloadable artifact, as listed in the hosted models manifest. */
30
+ export interface ModelManifestEntry {
31
+ /** Stable identifier, e.g. "whisper-large-v3-turbo". */
32
+ id: string;
33
+ /** Absolute download URL. */
34
+ url: string;
35
+ /** Lowercase hex SHA-256 of the complete file. */
36
+ sha256: string;
37
+ /** Expected size in bytes (for progress + sanity check). */
38
+ sizeBytes: number;
39
+ /** Install tier this artifact belongs to. */
40
+ tier: "T1" | "T2";
41
+ /** Final on-disk path; `~` is expanded by the caller (see resolvePath). */
42
+ destPath: string;
43
+ }
44
+
45
+ export interface DownloadProgress {
46
+ id: string;
47
+ receivedBytes: number;
48
+ totalBytes: number;
49
+ /** 0..1, or null when total size is unknown. */
50
+ fraction: number | null;
51
+ }
52
+
53
+ export type ProgressHandler = (p: DownloadProgress) => void;
54
+
55
+ export class IntegrityError extends Error {
56
+ constructor(
57
+ public readonly id: string,
58
+ public readonly expected: string,
59
+ public readonly actual: string,
60
+ ) {
61
+ super(`integrity check failed for ${id}: expected ${expected}, got ${actual}`);
62
+ this.name = "IntegrityError";
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Download a single manifest entry with resume + integrity + atomic install.
68
+ * Returns the final installed path on success; throws IntegrityError on a
69
+ * checksum mismatch or Error on network/IO failure.
70
+ */
71
+ export async function downloadModel(
72
+ entry: ModelManifestEntry,
73
+ onProgress?: ProgressHandler,
74
+ signal?: AbortSignal,
75
+ ): Promise<string> {
76
+ const finalPath = entry.destPath;
77
+ const partPath = `${finalPath}.part`;
78
+
79
+ await mkdir(dirname(finalPath), { recursive: true });
80
+
81
+ // If a complete, valid file already exists, skip the download.
82
+ if (await fileMatches(finalPath, entry.sha256)) {
83
+ log(TAG, `${entry.id}: already present and verified`);
84
+ return finalPath;
85
+ }
86
+
87
+ // Resume from an existing partial download if present.
88
+ let startByte = 0;
89
+ try {
90
+ startByte = (await stat(partPath)).size;
91
+ } catch {
92
+ startByte = 0; // no .part yet
93
+ }
94
+
95
+ const headers: Record<string, string> = {};
96
+ if (startByte > 0) {
97
+ headers["Range"] = `bytes=${startByte}-`;
98
+ log(TAG, `${entry.id}: resuming from ${startByte} bytes`);
99
+ }
100
+
101
+ const res = await fetch(entry.url, { headers, signal });
102
+ if (!res.ok && res.status !== 206) {
103
+ throw new Error(`${entry.id}: download failed — HTTP ${res.status}`);
104
+ }
105
+ if (!res.body) {
106
+ throw new Error(`${entry.id}: response had no body`);
107
+ }
108
+
109
+ // If the server ignored Range (200 instead of 206), restart from scratch.
110
+ const append = res.status === 206 && startByte > 0;
111
+ if (!append) startByte = 0;
112
+
113
+ const total = entry.sizeBytes;
114
+ let received = startByte;
115
+
116
+ const fileStream = createWriteStream(partPath, { flags: append ? "a" : "w" });
117
+ const body = Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]);
118
+ body.on("data", (chunk: Buffer) => {
119
+ received += chunk.length;
120
+ onProgress?.({
121
+ id: entry.id,
122
+ receivedBytes: received,
123
+ totalBytes: total,
124
+ fraction: total > 0 ? Math.min(received / total, 1) : null,
125
+ });
126
+ });
127
+
128
+ await pipeline(body, fileStream);
129
+
130
+ // Verify the completed .part before promoting it.
131
+ const actual = await sha256File(partPath);
132
+ if (actual !== entry.sha256) {
133
+ throw new IntegrityError(entry.id, entry.sha256, actual);
134
+ }
135
+
136
+ // Atomic install: rename is atomic within the same filesystem.
137
+ await rename(partPath, finalPath);
138
+ log(TAG, `${entry.id}: installed → ${finalPath}`);
139
+ return finalPath;
140
+ }
141
+
142
+ /** SHA-256 of a file as lowercase hex. */
143
+ export async function sha256File(path: string): Promise<string> {
144
+ const hash = createHash("sha256");
145
+ await pipeline(createReadStream(path), hash);
146
+ return hash.digest("hex");
147
+ }
148
+
149
+ /** True iff `path` exists and its SHA-256 equals `expectedSha256`. */
150
+ async function fileMatches(path: string, expectedSha256: string): Promise<boolean> {
151
+ try {
152
+ await stat(path);
153
+ } catch {
154
+ return false;
155
+ }
156
+ try {
157
+ return (await sha256File(path)) === expectedSha256;
158
+ } catch {
159
+ return false;
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Fetch the hosted models manifest (GitHub Pages).
165
+ *
166
+ * TODO(Phase 4): point at the real manifest URL once GitHub Pages hosting is
167
+ * set up (see docs/distribution/models-manifest.example.json for the schema),
168
+ * and decide how the manifest is versioned against app releases (open Q7).
169
+ */
170
+ export async function fetchManifest(_manifestUrl: string): Promise<ModelManifestEntry[]> {
171
+ throw new Error(
172
+ "fetchManifest not implemented — SEED-001 Phase 4. See docs/dmg-distribution-spec.md §5a.",
173
+ );
174
+ }
175
+
176
+ /**
177
+ * Download every entry for a given tier, sequentially.
178
+ *
179
+ * TODO(Phase 5): wire this into the first-run wizard's tier-config step so the
180
+ * whisper model downloads with a progress bar after the user picks T1/T2.
181
+ */
182
+ export async function downloadForTier(
183
+ entries: ModelManifestEntry[],
184
+ tier: "T1" | "T2",
185
+ onProgress?: ProgressHandler,
186
+ signal?: AbortSignal,
187
+ ): Promise<string[]> {
188
+ const wanted = entries.filter((e) => e.tier === tier || (tier === "T2" && e.tier === "T1"));
189
+ const installed: string[] = [];
190
+ for (const entry of wanted) {
191
+ try {
192
+ installed.push(await downloadModel(entry, onProgress, signal));
193
+ } catch (err) {
194
+ warn(TAG, `failed to download ${entry.id}:`, err);
195
+ throw err;
196
+ }
197
+ }
198
+ return installed;
199
+ }
@@ -29,6 +29,14 @@ import { initPrivacy, levelFor, applyLevel } from "./privacy/index.js";
29
29
 
30
30
  const TAG = "core";
31
31
 
32
+ /**
33
+ * Python interpreter for the sinain-memory scripts (graph_query, page_renderer,
34
+ * distillers). In a packaged build the launcher sets SINAIN_PYTHON to the one
35
+ * interpreter that has the deps — bare "python3" can resolve to a dep-less
36
+ * install and make knowledge pages silently fall back to empty.
37
+ */
38
+ const PYTHON_BIN = process.env.SINAIN_PYTHON || "python3";
39
+
32
40
  /** Resolve workspace path, expanding leading ~ to HOME. */
33
41
  function resolveWorkspace(): string {
34
42
  const raw = process.env.SINAIN_WORKSPACE || `${process.env.HOME}/.openclaw/workspace`;
@@ -70,7 +78,7 @@ async function queryKnowledgeFactsMulti(entities: string[], maxFacts: number): P
70
78
  try {
71
79
  const args = [scriptPath, "--db", dbPath, "--max-facts", String(maxFacts * 2), "--format", "json"];
72
80
  if (entities.length > 0) args.push("--entities", JSON.stringify(entities));
73
- const out = execFileSync("python3", args, { timeout: 5000, encoding: "utf-8" }).trim();
81
+ const out = execFileSync(PYTHON_BIN, args, { timeout: 5000, encoding: "utf-8" }).trim();
74
82
  if (out) {
75
83
  const parsed = JSON.parse(out);
76
84
  const facts = parsed.facts || parsed;
@@ -141,7 +149,7 @@ async function listKnowledgeEntitiesMulti(max: number): Promise<string> {
141
149
  for (const dbPath of dbPaths) {
142
150
  if (!existsSync(dbPath)) continue;
143
151
  try {
144
- const out = execFileSync("python3", [
152
+ const out = execFileSync(PYTHON_BIN, [
145
153
  scriptPath, "--db", dbPath, "--top", String(max), "--format", "json",
146
154
  ], { timeout: 5000, encoding: "utf-8" });
147
155
  const parsed = JSON.parse(out);
@@ -206,7 +214,7 @@ async function searchEntitiesMulti(query: string, limit: number): Promise<unknow
206
214
  for (const dbPath of resolveKnowledgeDbPaths()) {
207
215
  if (!existsSync(dbPath)) continue;
208
216
  try {
209
- const out = execFileSync("python3", [
217
+ const out = execFileSync(PYTHON_BIN, [
210
218
  scriptPath, "--db", dbPath,
211
219
  "--search-entities", query,
212
220
  "--search-limit", String(limit * 2), // 2x then de-dup
@@ -257,7 +265,7 @@ async function exportConceptBundle(
257
265
  if (opts.includePage) args.push("--include-page");
258
266
  try {
259
267
  // 30s budget — large 2-hop exports can take time on big graphs.
260
- const { stdout } = await pExecFile("python3", args,
268
+ const { stdout } = await pExecFile(PYTHON_BIN, args,
261
269
  { timeout: 30_000, encoding: "utf-8", maxBuffer: 50 * 1024 * 1024 });
262
270
  const parsed = JSON.parse(stdout);
263
271
  // If the export found at least one entity (the root), return it.
@@ -299,7 +307,7 @@ async function importConceptBundle(
299
307
  ];
300
308
  const { spawn } = await import("node:child_process");
301
309
  return await new Promise((resolve) => {
302
- const child = spawn("python3", args, { timeout: 30_000 });
310
+ const child = spawn(PYTHON_BIN, args, { timeout: 30_000 });
303
311
  let stdout = "";
304
312
  let stderr = "";
305
313
  child.stdout.on("data", (c: Buffer) => { stdout += c.toString("utf-8"); });
@@ -348,7 +356,7 @@ async function retractOrRestoreFact(
348
356
  if (opts.undoToken) args.push("--undo-token", opts.undoToken);
349
357
  }
350
358
  try {
351
- const { stdout } = await pExecFile("python3", args, { timeout: 10_000, encoding: "utf-8" });
359
+ const { stdout } = await pExecFile(PYTHON_BIN, args, { timeout: 10_000, encoding: "utf-8" });
352
360
  const parsed = JSON.parse(stdout);
353
361
  if (parsed.ok) return parsed;
354
362
  // If error is "fact not found" try the next DB; otherwise return the error
@@ -384,7 +392,7 @@ async function renderEntityPageMulti(
384
392
  if (opts.refresh) args.push("--refresh");
385
393
  try {
386
394
  // 60s budget — LLM rendering for large entities can take 20-30s.
387
- const { stdout } = await pExecFile("python3", args, { timeout: 60_000, encoding: "utf-8" });
395
+ const { stdout } = await pExecFile(PYTHON_BIN, args, { timeout: 60_000, encoding: "utf-8" });
388
396
  const parsed = JSON.parse(stdout);
389
397
  if (parsed.fact_count > 0) return parsed;
390
398
  } catch (e) {
@@ -411,7 +419,7 @@ async function graphChildrenMulti(entity: string): Promise<unknown> {
411
419
  for (const dbPath of resolveKnowledgeDbPaths()) {
412
420
  if (!existsSync(dbPath)) continue;
413
421
  try {
414
- const out = execFileSync("python3", [
422
+ const out = execFileSync(PYTHON_BIN, [
415
423
  scriptPath, "--db", dbPath,
416
424
  "--graph-children", entity,
417
425
  "--graph-limit", "50",
@@ -458,7 +466,7 @@ if not result:
458
466
  result = store.entity_as_of("${entity}", d)
459
467
  print(json.dumps({k: v for k, v in result.items()}, ensure_ascii=False))
460
468
  `;
461
- const out = execFileSync("python3", ["-c", pyCode], {
469
+ const out = execFileSync(PYTHON_BIN, ["-c", pyCode], {
462
470
  timeout: 5000, encoding: "utf-8",
463
471
  }).trim();
464
472
  if (out && out !== "{}") return out;
@@ -486,7 +494,7 @@ async function exportKnowledgeMulti(domain: string | null, max: number): Promise
486
494
  for (const dbPath of dbPaths) {
487
495
  if (!existsSync(dbPath)) continue;
488
496
  try {
489
- const out = execFileSync("python3", [
497
+ const out = execFileSync(PYTHON_BIN, [
490
498
  scriptPath, "--db", dbPath, "--top", String(max), "--format", "json",
491
499
  ], { timeout: 5000, encoding: "utf-8" });
492
500
  const parsed = JSON.parse(out);
@@ -608,7 +616,7 @@ store.close()
608
616
  print(json.dumps(stats))
609
617
  `;
610
618
 
611
- const result = execFileSync("python3", ["-c", script], {
619
+ const result = execFileSync(PYTHON_BIN, ["-c", script], {
612
620
  input: JSON.stringify(graphOps),
613
621
  timeout: 10_000,
614
622
  encoding: "utf-8",