@geravant/sinain 1.6.0 → 1.6.2

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/launcher.js CHANGED
@@ -127,7 +127,10 @@ async function main() {
127
127
  const scDir = path.join(PKG_DIR, "sense_client");
128
128
  // Check if key package is importable to skip pip
129
129
  try {
130
- execSync('python3 -c "import PIL; import skimage"', { stdio: "pipe" });
130
+ const depCheck = IS_WINDOWS
131
+ ? 'python3 -c "import PIL; import skimage"'
132
+ : 'python3 -c "import PIL; import skimage; import Quartz"';
133
+ execSync(depCheck, { stdio: "pipe" });
131
134
  } catch {
132
135
  log("Installing sense_client Python dependencies...");
133
136
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geravant/sinain",
3
- "version": "1.6.0",
3
+ "version": "1.6.2",
4
4
  "description": "Ambient AI overlay invisible to screen capture — real-time insights from audio + screen context",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,6 +16,7 @@
16
16
  "cli.js",
17
17
  "launcher.js",
18
18
  "setup-overlay.js",
19
+ "setup-sck-capture.js",
19
20
  "pack-prepare.js",
20
21
  "install.js",
21
22
  "index.ts",
@@ -12,6 +12,7 @@ if sys.platform == "win32":
12
12
 
13
13
  import argparse
14
14
  import concurrent.futures
15
+ import copy
15
16
  import json
16
17
  import os
17
18
  import time
@@ -28,7 +29,7 @@ from .capture import ScreenCapture, create_capture
28
29
  from .change_detector import ChangeDetector
29
30
  from .roi_extractor import ROIExtractor
30
31
  from .ocr import OCRResult, create_ocr
31
- from .gate import DecisionGate, SenseObservation
32
+ from .gate import DecisionGate, SenseEvent, SenseObservation, SenseMeta
32
33
  from .sender import SenseSender, package_full_frame, package_roi
33
34
  from .app_detector import AppDetector
34
35
  from .config import load_config
@@ -128,6 +129,7 @@ def main():
128
129
  )
129
130
  app_detector = AppDetector()
130
131
  ocr_pool = concurrent.futures.ThreadPoolExecutor(max_workers=4)
132
+ vision_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1, thread_name_prefix="vision")
131
133
 
132
134
  # Vision provider — routes to Ollama (local) or OpenRouter (cloud) based on config/privacy
133
135
  vision_cfg = config.get("vision", {})
@@ -355,18 +357,28 @@ def main():
355
357
  title=title, subtitle=subtitle, facts=facts,
356
358
  )
357
359
 
358
- # Vision scene analysis (throttled, non-blocking on failure)
360
+ # Vision scene analysis — async: send text event immediately, vision follows
359
361
  if vision_provider and time.time() - last_vision_time >= vision_throttle_s:
360
- try:
361
- from PIL import Image as PILImage
362
- pil_frame = PILImage.fromarray(use_frame) if isinstance(use_frame, np.ndarray) else use_frame
363
- scene = vision_provider.describe(pil_frame, prompt=vision_prompt or None)
364
- if scene:
365
- event.observation.scene = scene
366
- last_vision_time = time.time()
367
- log(f"vision: {scene[:80]}...")
368
- except Exception as e:
369
- log(f"vision error: {e}")
362
+ last_vision_time = time.time() # claim slot immediately to prevent concurrent calls
363
+ _v_frame = use_frame.copy() if isinstance(use_frame, np.ndarray) else use_frame.copy()
364
+ _v_meta = copy.copy(event.meta)
365
+ _v_ts = event.ts
366
+ _v_prompt = vision_prompt
367
+ def _do_vision(frame, meta, ts, prompt):
368
+ try:
369
+ from PIL import Image as PILImage
370
+ pil = PILImage.fromarray(frame) if isinstance(frame, np.ndarray) else frame
371
+ scene = vision_provider.describe(pil, prompt=prompt or None)
372
+ if scene:
373
+ log(f"vision: {scene[:80]}...")
374
+ ctx_ev = SenseEvent(type="context", ts=ts)
375
+ ctx_ev.observation = SenseObservation(scene=scene)
376
+ ctx_ev.meta = meta
377
+ ctx_ev.roi = package_full_frame(frame)
378
+ sender.send(ctx_ev)
379
+ except Exception as e:
380
+ log(f"vision error: {e}")
381
+ vision_pool.submit(_do_vision, _v_frame, _v_meta, _v_ts, _v_prompt)
370
382
 
371
383
  # Send small thumbnail for ALL event types (agent uses vision)
372
384
  # Privacy matrix: gate image sending based on PRIVACY_IMAGES_OPENROUTER
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env node
2
+ // sinain setup-sck-capture — download pre-built sck-capture binary from GitHub Releases
3
+
4
+ import { execSync } from "child_process";
5
+ import fs from "fs";
6
+ import path from "path";
7
+ import os from "os";
8
+
9
+ const HOME = os.homedir();
10
+ const SINAIN_DIR = path.join(HOME, ".sinain");
11
+ const INSTALL_DIR = path.join(SINAIN_DIR, "sck-capture");
12
+ const BINARY_PATH = path.join(INSTALL_DIR, "sck-capture");
13
+ const VERSION_FILE = path.join(INSTALL_DIR, "version.json");
14
+
15
+ const REPO = "anthillnet/sinain-hud";
16
+ const RELEASES_API = `https://api.github.com/repos/${REPO}/releases`;
17
+ const TAG_PREFIX = "sck-capture-v";
18
+ const ASSET_NAME = "sck-capture-macos.zip";
19
+
20
+ const BOLD = "\x1b[1m";
21
+ const GREEN = "\x1b[32m";
22
+ const YELLOW = "\x1b[33m";
23
+ const RED = "\x1b[31m";
24
+ const DIM = "\x1b[2m";
25
+ const RESET = "\x1b[0m";
26
+
27
+ function log(msg) { console.log(`${BOLD}[setup-sck-capture]${RESET} ${msg}`); }
28
+ function ok(msg) { console.log(`${BOLD}[setup-sck-capture]${RESET} ${GREEN}✓${RESET} ${msg}`); }
29
+ function warn(msg) { console.log(`${BOLD}[setup-sck-capture]${RESET} ${YELLOW}⚠${RESET} ${msg}`); }
30
+ function fail(msg) { console.error(`${BOLD}[setup-sck-capture]${RESET} ${RED}✗${RESET} ${msg}`); process.exit(1); }
31
+
32
+ // ── Entry point (only when run directly, not when imported) ──────────────────
33
+
34
+ const isMain = process.argv[1] && (
35
+ import.meta.url === `file://${process.argv[1]}` ||
36
+ import.meta.url === new URL(process.argv[1], "file://").href
37
+ );
38
+
39
+ if (isMain) {
40
+ const args = process.argv.slice(2);
41
+ const forceUpdate = args.includes("--update");
42
+
43
+ if (os.platform() === "win32") {
44
+ log("sck-capture is macOS-only (Windows uses win-audio-capture.exe)");
45
+ process.exit(0);
46
+ }
47
+
48
+ await downloadBinary({ forceUpdate });
49
+ }
50
+
51
+ // ── Download pre-built binary ────────────────────────────────────────────────
52
+
53
+ export async function downloadBinary({ silent = false, forceUpdate = false } = {}) {
54
+ const _log = silent ? () => {} : log;
55
+ const _ok = silent ? () => {} : ok;
56
+ const _warn = silent ? () => {} : warn;
57
+
58
+ fs.mkdirSync(INSTALL_DIR, { recursive: true });
59
+
60
+ // Find latest sck-capture release
61
+ _log("Checking for latest sck-capture release...");
62
+ let release;
63
+ try {
64
+ const res = await fetch(`${RELEASES_API}?per_page=30`, {
65
+ signal: AbortSignal.timeout(10000),
66
+ headers: { "Accept": "application/vnd.github+json" },
67
+ });
68
+ if (!res.ok) throw new Error(`GitHub API returned ${res.status}`);
69
+ const releases = await res.json();
70
+ release = releases.find(r => r.tag_name?.startsWith(TAG_PREFIX));
71
+ if (!release) throw new Error("No sck-capture release found");
72
+ } catch (e) {
73
+ if (silent) {
74
+ _warn(`Failed to fetch sck-capture release: ${e.message}`);
75
+ return false;
76
+ }
77
+ fail(`Failed to fetch releases: ${e.message}`);
78
+ }
79
+
80
+ const tag = release.tag_name;
81
+ const version = tag.replace(TAG_PREFIX, "");
82
+
83
+ // Check if already up-to-date
84
+ if (!forceUpdate && fs.existsSync(VERSION_FILE) && fs.existsSync(BINARY_PATH)) {
85
+ try {
86
+ const local = JSON.parse(fs.readFileSync(VERSION_FILE, "utf-8"));
87
+ if (local.tag === tag) {
88
+ _ok(`sck-capture already up-to-date (${version})`);
89
+ return true;
90
+ }
91
+ _log(`Updating: ${local.tag} → ${tag}`);
92
+ } catch { /* corrupt version file — re-download */ }
93
+ }
94
+
95
+ // Find the .zip asset
96
+ const zipAsset = release.assets?.find(a => a.name === ASSET_NAME);
97
+ if (!zipAsset) {
98
+ if (silent) {
99
+ _warn(`Release ${tag} has no ${ASSET_NAME} asset`);
100
+ return false;
101
+ }
102
+ fail(`Release ${tag} has no ${ASSET_NAME} asset`);
103
+ }
104
+
105
+ // Download
106
+ _log(`Downloading sck-capture ${version} (${formatBytes(zipAsset.size)})...`);
107
+ const zipPath = path.join(INSTALL_DIR, ASSET_NAME);
108
+
109
+ try {
110
+ const res = await fetch(zipAsset.browser_download_url, {
111
+ signal: AbortSignal.timeout(60000),
112
+ redirect: "follow",
113
+ });
114
+ if (!res.ok) throw new Error(`Download failed: ${res.status}`);
115
+
116
+ const total = parseInt(res.headers.get("content-length") || "0");
117
+ const chunks = [];
118
+ let downloaded = 0;
119
+
120
+ const reader = res.body.getReader();
121
+ while (true) {
122
+ const { done, value } = await reader.read();
123
+ if (done) break;
124
+ chunks.push(value);
125
+ downloaded += value.length;
126
+ if (!silent && total > 0) {
127
+ const pct = Math.round((downloaded / total) * 100);
128
+ process.stdout.write(`\r${BOLD}[setup-sck-capture]${RESET} ${DIM}${pct}% (${formatBytes(downloaded)} / ${formatBytes(total)})${RESET}`);
129
+ }
130
+ }
131
+ if (!silent) process.stdout.write("\n");
132
+
133
+ const buffer = Buffer.concat(chunks);
134
+ fs.writeFileSync(zipPath, buffer);
135
+ _ok(`Downloaded ${formatBytes(buffer.length)}`);
136
+ } catch (e) {
137
+ if (silent) {
138
+ _warn(`Download failed: ${e.message}`);
139
+ return false;
140
+ }
141
+ fail(`Download failed: ${e.message}`);
142
+ }
143
+
144
+ // Remove old binary if present
145
+ if (fs.existsSync(BINARY_PATH)) {
146
+ fs.unlinkSync(BINARY_PATH);
147
+ }
148
+
149
+ // Extract
150
+ _log("Extracting...");
151
+ try {
152
+ execSync(`ditto -x -k "${zipPath}" "${INSTALL_DIR}"`, { stdio: "pipe" });
153
+ } catch {
154
+ try {
155
+ execSync(`unzip -o -q "${zipPath}" -d "${INSTALL_DIR}"`, { stdio: "pipe" });
156
+ } catch (e) {
157
+ if (silent) {
158
+ _warn(`Extraction failed: ${e.message}`);
159
+ return false;
160
+ }
161
+ fail(`Extraction failed: ${e.message}`);
162
+ }
163
+ }
164
+
165
+ // Make executable
166
+ try {
167
+ fs.chmodSync(BINARY_PATH, 0o755);
168
+ } catch { /* may not exist if zip structure differs */ }
169
+
170
+ // Write version marker
171
+ fs.writeFileSync(VERSION_FILE, JSON.stringify({
172
+ tag,
173
+ version,
174
+ installedAt: new Date().toISOString(),
175
+ }, null, 2));
176
+
177
+ // Clean up zip
178
+ fs.unlinkSync(zipPath);
179
+
180
+ _ok(`sck-capture ${version} installed → ${BINARY_PATH}`);
181
+ return true;
182
+ }
183
+
184
+ // ── Helpers ──────────────────────────────────────────────────────────────────
185
+
186
+ function formatBytes(bytes) {
187
+ if (bytes < 1024) return `${bytes} B`;
188
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
189
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
190
+ }
@@ -1,9 +1,10 @@
1
1
  import { EventEmitter } from "node:events";
2
+ import fs from "node:fs";
2
3
  import type { FeedBuffer } from "../buffers/feed-buffer.js";
3
4
  import type { SenseBuffer } from "../buffers/sense-buffer.js";
4
- import type { AgentConfig, AgentEntry, ContextWindow, EscalationMode, ContextRichness, RecorderStatus } from "../types.js";
5
+ import type { AgentConfig, AgentEntry, ContextWindow, EscalationMode, ContextRichness, RecorderStatus, SenseEvent, FeedbackRecord } from "../types.js";
5
6
  import type { Profiler } from "../profiler.js";
6
- import { buildContextWindow } from "./context-window.js";
7
+ import { buildContextWindow, RICHNESS_PRESETS } from "./context-window.js";
7
8
  import { analyzeContext } from "./analyzer.js";
8
9
  import { writeSituationMd } from "./situation-writer.js";
9
10
  import { calculateEscalationScore } from "../escalation/scorer.js";
@@ -35,6 +36,10 @@ export interface AgentLoopDeps {
35
36
  traitEngine?: TraitEngine;
36
37
  /** Directory to write per-day trait log JSONL files. */
37
38
  traitLogDir?: string;
39
+ /** Optional: path to sinain-knowledge.md for startup recap. */
40
+ getKnowledgeDocPath?: () => string | null;
41
+ /** Optional: feedback store for startup recap context. */
42
+ feedbackStore?: { queryRecent(n: number): FeedbackRecord[] };
38
43
  }
39
44
 
40
45
  export interface TraceContext {
@@ -69,6 +74,7 @@ export class AgentLoop extends EventEmitter {
69
74
  private lastRunTs = 0;
70
75
  private running = false;
71
76
  private started = false;
77
+ private firstTick = true;
72
78
 
73
79
  private lastPushedHud = "";
74
80
  private agentNextId = 1;
@@ -112,6 +118,9 @@ export class AgentLoop extends EventEmitter {
112
118
  }, this.deps.agentConfig.maxIntervalMs);
113
119
 
114
120
  log(TAG, `loop started (debounce=${this.deps.agentConfig.debounceMs}ms, max=${this.deps.agentConfig.maxIntervalMs}ms, cooldown=${this.deps.agentConfig.cooldownMs}ms, model=${this.deps.agentConfig.model})`);
121
+
122
+ // Fire recap tick: immediate HUD from persistent knowledge (no sense data needed)
123
+ this.fireRecapTick().catch(e => debug(TAG, "recap skipped:", String(e)));
115
124
  }
116
125
 
117
126
  /** Stop the agent loop. */
@@ -131,12 +140,13 @@ export class AgentLoop extends EventEmitter {
131
140
  onNewContext(): void {
132
141
  if (!this.started) return;
133
142
 
134
- // Debounce: wait N ms after last event before running
143
+ // Fast first tick: 500ms debounce on startup, normal debounce after
144
+ const delay = this.firstTick ? 500 : this.deps.agentConfig.debounceMs;
135
145
  if (this.debounceTimer) clearTimeout(this.debounceTimer);
136
146
  this.debounceTimer = setTimeout(() => {
137
147
  this.debounceTimer = null;
138
148
  this.run().catch(err => error(TAG, "debounce tick error:", err.message));
139
- }, this.deps.agentConfig.debounceMs);
149
+ }, delay);
140
150
  }
141
151
 
142
152
  /** Get agent results history (newest first). */
@@ -398,7 +408,80 @@ export class AgentLoop extends EventEmitter {
398
408
  traceCtx?.finish({ totalLatencyMs: Date.now() - Date.now(), llmLatencyMs: 0, llmInputTokens: 0, llmOutputTokens: 0, llmCost: 0, escalated: false, escalationScore: 0, contextScreenEvents: 0, contextAudioEntries: 0, contextRichness: richness, digestLength: 0, hudChanged: false });
399
409
  } finally {
400
410
  this.running = false;
411
+ this.firstTick = false;
401
412
  this.lastRunTs = Date.now();
402
413
  }
403
414
  }
415
+
416
+ // ── Private: startup recap tick from persistent knowledge ──
417
+
418
+ private async fireRecapTick(): Promise<void> {
419
+ if (this.running) return;
420
+ this.running = true;
421
+
422
+ try {
423
+ const sections: string[] = [];
424
+ const startTs = Date.now();
425
+
426
+ // 1. sinain-knowledge.md (established patterns, user preferences)
427
+ const knowledgePath = this.deps.getKnowledgeDocPath?.();
428
+ if (knowledgePath) {
429
+ const content = await fs.promises.readFile(knowledgePath, "utf-8").catch(() => "");
430
+ if (content.length > 50) sections.push(content.slice(0, 2000));
431
+ }
432
+
433
+ // 2. SITUATION.md digest (if fresh — less than 5 minutes old)
434
+ try {
435
+ const stat = await fs.promises.stat(this.deps.situationMdPath);
436
+ if (Date.now() - stat.mtimeMs < 5 * 60_000) {
437
+ const sit = await fs.promises.readFile(this.deps.situationMdPath, "utf-8");
438
+ const digestMatch = sit.match(/## Digest\n([\s\S]*?)(?=\n##|$)/);
439
+ if (digestMatch?.[1]?.trim()) {
440
+ sections.push(`Last session digest:\n${digestMatch[1].trim()}`);
441
+ }
442
+ }
443
+ } catch { /* SITUATION.md missing — fine */ }
444
+
445
+ // 3. Recent feedback records (last 5 escalation summaries)
446
+ const records = this.deps.feedbackStore?.queryRecent(5) ?? [];
447
+ if (records.length > 0) {
448
+ const recaps = records.slice(0, 5).map(r => `- ${r.currentApp}: ${r.hud}`).join("\n");
449
+ sections.push(`Recent activity:\n${recaps}`);
450
+ }
451
+
452
+ if (sections.length === 0) { return; }
453
+
454
+ const recapContext = sections.join("\n\n");
455
+
456
+ // Build synthetic ContextWindow with knowledge as screen entry
457
+ const recapWindow: ContextWindow = {
458
+ audio: [],
459
+ screen: [{
460
+ ts: Date.now(),
461
+ ocr: recapContext,
462
+ meta: { app: "sinain-recap", windowTitle: "startup" },
463
+ type: "context",
464
+ } as unknown as SenseEvent],
465
+ images: [],
466
+ currentApp: "sinain-recap",
467
+ appHistory: [],
468
+ audioCount: 0,
469
+ screenCount: 1,
470
+ windowMs: 0,
471
+ newestEventTs: Date.now(),
472
+ preset: RICHNESS_PRESETS.lean,
473
+ };
474
+
475
+ const result = await analyzeContext(recapWindow, this.deps.agentConfig, null);
476
+ if (result?.hud && result.hud !== "—" && result.hud !== "Idle") {
477
+ this.deps.onHudUpdate(result.hud);
478
+ log(TAG, `recap tick (${Date.now() - startTs}ms, ${result.tokensIn}in+${result.tokensOut}out tok) hud="${result.hud}"`);
479
+ }
480
+ } catch (err: any) {
481
+ debug(TAG, "recap tick error:", err.message || err);
482
+ } finally {
483
+ this.running = false;
484
+ // Do NOT update lastRunTs — normal cooldown should not be affected by recap
485
+ }
486
+ }
404
487
  }
@@ -99,7 +99,7 @@ export class AudioPipeline extends EventEmitter {
99
99
  return;
100
100
  }
101
101
 
102
- log(TAG, `starting capture: device=${this.config.device} cmd=${this.config.captureCommand} rate=${this.config.sampleRate}`);
102
+ log(TAG, `starting capture: device=${this.config.device} cmd=${this.config.captureCommand} rate=${this.config.sampleRate}${this.config.gainDb ? ` gain=${this.config.gainDb}dB` : ""}`);
103
103
 
104
104
  try {
105
105
  this.spawnCaptureProcess();
@@ -295,6 +295,16 @@ export class AudioPipeline extends EventEmitter {
295
295
 
296
296
  if (alignedPcm.length === 0) return;
297
297
 
298
+ // Apply gain (amplify quiet ScreenCaptureKit audio before VAD + transcription)
299
+ if (this.config.gainDb !== 0) {
300
+ const multiplier = Math.pow(10, this.config.gainDb / 20);
301
+ for (let i = 0; i < alignedPcm.length - 1; i += 2) {
302
+ const sample = alignedPcm.readInt16LE(i);
303
+ const amplified = Math.max(-32768, Math.min(32767, Math.round(sample * multiplier)));
304
+ alignedPcm.writeInt16LE(amplified, i);
305
+ }
306
+ }
307
+
298
308
  const energy = calculateRmsEnergy(alignedPcm);
299
309
  this.profiler?.gauge("audio.lastChunkKb", Math.round(alignedPcm.length / 1024));
300
310
 
@@ -492,7 +492,6 @@ ${recentLines.join("\n")}`;
492
492
 
493
493
  // Generate a unique child session key — bypasses the main agent entirely
494
494
  const childSessionKey = `agent:main:subagent:${randomUUID()}`;
495
- const mainSessionKey = this.deps.openclawConfig.sessionKey;
496
495
 
497
496
  this.outboundBytes += Buffer.byteLength(task);
498
497
  this.deps.profiler?.gauge("network.escalationOutBytes", this.outboundBytes);
@@ -523,7 +522,6 @@ ${recentLines.join("\n")}`;
523
522
  lane: "subagent",
524
523
  extraSystemPrompt: this.buildChildSystemPrompt(task, label),
525
524
  deliver: false,
526
- spawnedBy: mainSessionKey,
527
525
  idempotencyKey: idemKey,
528
526
  label: label || undefined,
529
527
  }, 45_000, { expectFinal: true });
@@ -25,6 +25,12 @@ import { initPrivacy, levelFor, applyLevel } from "./privacy/index.js";
25
25
 
26
26
  const TAG = "core";
27
27
 
28
+ /** Resolve workspace path, expanding leading ~ to HOME. */
29
+ function resolveWorkspace(): string {
30
+ const raw = process.env.SINAIN_WORKSPACE || `${process.env.HOME}/.openclaw/workspace`;
31
+ return raw.startsWith("~") ? raw.replace("~", process.env.HOME || "") : raw;
32
+ }
33
+
28
34
  async function main() {
29
35
  log(TAG, "sinain-core starting...");
30
36
 
@@ -80,7 +86,7 @@ async function main() {
80
86
  profiler,
81
87
  feedbackStore: feedbackStore ?? undefined,
82
88
  queryKnowledgeFacts: async (entities: string[], maxFacts: number) => {
83
- const workspace = process.env.SINAIN_WORKSPACE || `${process.env.HOME}/.openclaw/workspace`;
89
+ const workspace = resolveWorkspace();
84
90
  const dbPath = `${workspace}/memory/knowledge-graph.db`;
85
91
  const scriptPath = `${workspace}/sinain-memory/graph_query.py`;
86
92
  try {
@@ -156,6 +162,13 @@ async function main() {
156
162
  } : undefined,
157
163
  traitEngine,
158
164
  traitLogDir: config.traitConfig.logDir,
165
+ getKnowledgeDocPath: () => {
166
+ const workspace = resolveWorkspace();
167
+ const p = `${workspace}/memory/sinain-knowledge.md`;
168
+ try { if (existsSync(p)) return p; } catch {}
169
+ return null;
170
+ },
171
+ feedbackStore: feedbackStore ?? undefined,
159
172
  });
160
173
 
161
174
  // ── Wire learning signal collector (needs agentLoop) ──
@@ -400,13 +413,13 @@ async function main() {
400
413
 
401
414
  // Knowledge graph integration
402
415
  getKnowledgeDocPath: () => {
403
- const workspace = process.env.SINAIN_WORKSPACE || `${process.env.HOME}/.openclaw/workspace`;
416
+ const workspace = resolveWorkspace();
404
417
  const p = `${workspace}/memory/sinain-knowledge.md`;
405
418
  try { if (existsSync(p)) return p; } catch {}
406
419
  return null;
407
420
  },
408
421
  queryKnowledgeFacts: async (entities: string[], maxFacts: number) => {
409
- const workspace = process.env.SINAIN_WORKSPACE || `${process.env.HOME}/.openclaw/workspace`;
422
+ const workspace = resolveWorkspace();
410
423
  const dbPath = `${workspace}/memory/knowledge-graph.db`;
411
424
  const scriptPath = `${workspace}/sinain-memory/graph_query.py`;
412
425
  try {