@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 +12 -1
- package/launcher.js +1 -2
- package/onboard.js +6 -3
- package/package.json +1 -1
- package/sense_client/config.py +1 -1
- package/sense_client/ollama_vision.py +3 -3
- package/sense_client/vision.py +14 -3
- package/setup-overlay.js +1 -1
- package/sinain-agent/run.sh +67 -1
- package/sinain-core/src/config.ts +1 -1
- package/sinain-core/src/distribution/download-manager.ts +199 -0
- package/sinain-core/src/index.ts +19 -11
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
|
|
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 —
|
|
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
|
-
|
|
72
|
-
if (choice === "source")
|
|
73
|
-
|
|
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.
|
|
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": {
|
package/sense_client/config.py
CHANGED
|
@@ -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 (
|
|
4
|
-
moondream,
|
|
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 = "
|
|
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,
|
package/sense_client/vision.py
CHANGED
|
@@ -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 = "
|
|
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.
|
|
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 =
|
|
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" });
|
package/sinain-agent/run.sh
CHANGED
|
@@ -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", "
|
|
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
|
+
}
|
package/sinain-core/src/index.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
619
|
+
const result = execFileSync(PYTHON_BIN, ["-c", script], {
|
|
612
620
|
input: JSON.stringify(graphOps),
|
|
613
621
|
timeout: 10_000,
|
|
614
622
|
encoding: "utf-8",
|