@geravant/sinain 1.13.0 → 1.15.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.
Files changed (78) hide show
  1. package/.env.example +33 -27
  2. package/cli.js +30 -14
  3. package/config-shared.js +173 -30
  4. package/launcher.js +38 -21
  5. package/onboard.js +36 -20
  6. package/package.json +4 -1
  7. package/sinain-agent/run.sh +600 -127
  8. package/sinain-core/src/agents-loader.ts +254 -0
  9. package/sinain-core/src/buffers/feed-buffer.ts +6 -4
  10. package/sinain-core/src/config.ts +77 -15
  11. package/sinain-core/src/escalation/escalator.ts +178 -18
  12. package/sinain-core/src/index.ts +218 -31
  13. package/sinain-core/src/learning/local-curation.ts +81 -27
  14. package/sinain-core/src/overlay/commands.ts +25 -0
  15. package/sinain-core/src/overlay/ws-handler.ts +3 -0
  16. package/sinain-core/src/server.ts +101 -10
  17. package/sinain-core/src/types.ts +29 -3
  18. package/sinain-memory/graph_query.py +12 -3
  19. package/sinain-memory/knowledge_integrator.py +194 -10
  20. package/sinain-memory/__pycache__/common.cpython-312.pyc +0 -0
  21. package/sinain-memory/__pycache__/embed_client.cpython-312.pyc +0 -0
  22. package/sinain-memory/__pycache__/graph_query.cpython-312.pyc +0 -0
  23. package/sinain-memory/__pycache__/knowledge_integrator.cpython-312.pyc +0 -0
  24. package/sinain-memory/__pycache__/session_distiller.cpython-312.pyc +0 -0
  25. package/sinain-memory/__pycache__/triplestore.cpython-312.pyc +0 -0
  26. package/sinain-memory/eval/__init__.py +0 -0
  27. package/sinain-memory/eval/__pycache__/__init__.cpython-312.pyc +0 -0
  28. package/sinain-memory/eval/assertions.py +0 -267
  29. package/sinain-memory/eval/benchmarks/__init__.py +0 -0
  30. package/sinain-memory/eval/benchmarks/__pycache__/__init__.cpython-312.pyc +0 -0
  31. package/sinain-memory/eval/benchmarks/__pycache__/base_adapter.cpython-312.pyc +0 -0
  32. package/sinain-memory/eval/benchmarks/__pycache__/config.cpython-312.pyc +0 -0
  33. package/sinain-memory/eval/benchmarks/__pycache__/evaluate.cpython-312.pyc +0 -0
  34. package/sinain-memory/eval/benchmarks/__pycache__/ingest.cpython-312.pyc +0 -0
  35. package/sinain-memory/eval/benchmarks/__pycache__/longmemeval_adapter.cpython-312.pyc +0 -0
  36. package/sinain-memory/eval/benchmarks/__pycache__/meeting_adapter.cpython-312.pyc +0 -0
  37. package/sinain-memory/eval/benchmarks/__pycache__/meeting_runner.cpython-312.pyc +0 -0
  38. package/sinain-memory/eval/benchmarks/__pycache__/query.cpython-312.pyc +0 -0
  39. package/sinain-memory/eval/benchmarks/__pycache__/report.cpython-312.pyc +0 -0
  40. package/sinain-memory/eval/benchmarks/__pycache__/runner.cpython-312.pyc +0 -0
  41. package/sinain-memory/eval/benchmarks/base_adapter.py +0 -43
  42. package/sinain-memory/eval/benchmarks/config.py +0 -23
  43. package/sinain-memory/eval/benchmarks/evaluate.py +0 -146
  44. package/sinain-memory/eval/benchmarks/ingest.py +0 -152
  45. package/sinain-memory/eval/benchmarks/judges/__init__.py +0 -0
  46. package/sinain-memory/eval/benchmarks/judges/__pycache__/__init__.cpython-312.pyc +0 -0
  47. package/sinain-memory/eval/benchmarks/judges/__pycache__/qa_judge.cpython-312.pyc +0 -0
  48. package/sinain-memory/eval/benchmarks/judges/qa_judge.py +0 -81
  49. package/sinain-memory/eval/benchmarks/longmemeval_adapter.py +0 -177
  50. package/sinain-memory/eval/benchmarks/meeting_adapter.py +0 -81
  51. package/sinain-memory/eval/benchmarks/meeting_runner.py +0 -230
  52. package/sinain-memory/eval/benchmarks/query.py +0 -193
  53. package/sinain-memory/eval/benchmarks/report.py +0 -87
  54. package/sinain-memory/eval/benchmarks/run_meeting_bench.sh +0 -318
  55. package/sinain-memory/eval/benchmarks/runner.py +0 -283
  56. package/sinain-memory/eval/judges/__init__.py +0 -0
  57. package/sinain-memory/eval/judges/base_judge.py +0 -61
  58. package/sinain-memory/eval/judges/curation_judge.py +0 -46
  59. package/sinain-memory/eval/judges/insight_judge.py +0 -48
  60. package/sinain-memory/eval/judges/mining_judge.py +0 -42
  61. package/sinain-memory/eval/judges/signal_judge.py +0 -45
  62. package/sinain-memory/eval/retrieval_benchmark.jsonl +0 -12
  63. package/sinain-memory/eval/retrieval_evaluator.py +0 -186
  64. package/sinain-memory/eval/schemas.py +0 -247
  65. package/sinain-memory/tests/__init__.py +0 -0
  66. package/sinain-memory/tests/conftest.py +0 -189
  67. package/sinain-memory/tests/test_curator_helpers.py +0 -94
  68. package/sinain-memory/tests/test_embedder.py +0 -210
  69. package/sinain-memory/tests/test_extract_json.py +0 -124
  70. package/sinain-memory/tests/test_feedback_computation.py +0 -121
  71. package/sinain-memory/tests/test_miner_helpers.py +0 -71
  72. package/sinain-memory/tests/test_module_management.py +0 -458
  73. package/sinain-memory/tests/test_parsers.py +0 -96
  74. package/sinain-memory/tests/test_tick_evaluator.py +0 -430
  75. package/sinain-memory/tests/test_triple_extractor.py +0 -255
  76. package/sinain-memory/tests/test_triple_ingest.py +0 -191
  77. package/sinain-memory/tests/test_triple_migrate.py +0 -138
  78. package/sinain-memory/tests/test_triplestore.py +0 -248
@@ -0,0 +1,254 @@
1
+ // agents-loader.ts
2
+ //
3
+ // sinain-core's view of sinain-agent/agents.json. Loaded once at startup,
4
+ // gives config.ts a typed snapshot of the bare-agent + OpenClaw config that
5
+ // used to live in .env.
6
+ //
7
+ // Why sinain-core reads it at all: the OpenClaw WS client is constructed
8
+ // before the bare agent ever registers, and stays connected across the
9
+ // session for heartbeat / situation push / feedback. So sinain-core needs
10
+ // the gateway URL/token at startup — it can't wait for run.sh to forward
11
+ // them via /bareagent/register.
12
+ //
13
+ // Path resolution:
14
+ // 1. AGENTS_CONFIG_PATH env var (explicit override)
15
+ // 2. <repo-root>/sinain-agent/agents.json (default)
16
+ // 3. <repo-root>/sinain-agent/agents.example.json (fresh-checkout fallback)
17
+ //
18
+ // Returns null if no candidate exists. config.ts treats null as "use env
19
+ // defaults" so the migration is non-breaking.
20
+ //
21
+ // Field expansion happens in two passes (shell-like ordering):
22
+ // 1. Cross-field literal expansion: ${allowedTools} inside escAllowedTools
23
+ // resolves against other top-level fields. Lets users compose
24
+ // whitelists by reference.
25
+ // 2. Env-var indirection: ${OPENROUTER_API_KEY}, ${HOME}, etc. resolve
26
+ // against process.env. Mirrors run.sh's apply_profile_env regex
27
+ // (\$\{[A-Za-z_][A-Za-z0-9_]*\}).
28
+
29
+ import { readFileSync, existsSync } from "node:fs";
30
+ import { resolve, dirname } from "node:path";
31
+ import { fileURLToPath } from "node:url";
32
+ import { homedir } from "node:os";
33
+
34
+ const __dirname = dirname(fileURLToPath(import.meta.url));
35
+
36
+ // Regex shared with run.sh's apply_profile_env. Keep the two implementations
37
+ // in lockstep when changing either side.
38
+ const VAR_REF_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
39
+
40
+ export interface OpenClawProfileFields {
41
+ type: "openclaw";
42
+ wsUrl?: string;
43
+ wsToken?: string;
44
+ httpUrl?: string;
45
+ httpToken?: string;
46
+ sessionKey?: string;
47
+ phase1TimeoutMs?: number;
48
+ phase2TimeoutMs?: number;
49
+ pingIntervalMs?: number;
50
+ }
51
+
52
+ export interface AgentProfile {
53
+ type?: string;
54
+ bin?: string;
55
+ settings?: string;
56
+ model?: string;
57
+ env?: Record<string, string>;
58
+ // openclaw-specific fields (typed loose since profiles are heterogeneous)
59
+ wsUrl?: string;
60
+ wsToken?: string;
61
+ httpUrl?: string;
62
+ httpToken?: string;
63
+ sessionKey?: string;
64
+ phase1TimeoutMs?: number;
65
+ phase2TimeoutMs?: number;
66
+ pingIntervalMs?: number;
67
+ }
68
+
69
+ export interface AnalyzerBlock {
70
+ debounceMs?: number;
71
+ maxIntervalMs?: number;
72
+ }
73
+
74
+ export interface EscalationBlock {
75
+ mode?: string;
76
+ cooldownMs?: number;
77
+ staleMs?: number;
78
+ }
79
+
80
+ export interface AgentsConfig {
81
+ default?: string;
82
+ pollIntervalSec?: number;
83
+ agentMaxTurns?: number;
84
+ spawnMaxTurns?: number;
85
+ allowedTools?: string;
86
+ escAllowedTools?: string;
87
+ spawnAllowedTools?: string;
88
+ autoApproveTools?: string;
89
+ analyzer?: AnalyzerBlock;
90
+ escalation?: EscalationBlock;
91
+ profiles?: Record<string, AgentProfile>;
92
+ /** Absolute path of the file actually loaded (for debug logging). */
93
+ loadedFrom?: string;
94
+ }
95
+
96
+ function expandVars(value: string): string {
97
+ return value.replace(VAR_REF_RE, (_, name: string) => process.env[name] ?? "");
98
+ }
99
+
100
+ /** Recursively expand ${env} references in any string field of `obj`. */
101
+ function expandEnvDeep<T>(obj: T): T {
102
+ if (obj === null || obj === undefined) return obj;
103
+ if (typeof obj === "string") return expandVars(obj) as unknown as T;
104
+ if (Array.isArray(obj)) return obj.map((x) => expandEnvDeep(x)) as unknown as T;
105
+ if (typeof obj === "object") {
106
+ const out: Record<string, unknown> = {};
107
+ for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
108
+ out[k] = expandEnvDeep(v);
109
+ }
110
+ return out as T;
111
+ }
112
+ return obj;
113
+ }
114
+
115
+ /**
116
+ * Cross-field literal expansion at the top level. Lets users write
117
+ * "escAllowedTools": "${allowedTools} Bash(git:*) Edit"
118
+ * and have it compose with the value of `allowedTools`. Only string
119
+ * top-level fields are eligible to be referenced; nested blocks aren't.
120
+ */
121
+ function expandTopLevelRefs(cfg: AgentsConfig): void {
122
+ const refTargets: Record<string, string | undefined> = {
123
+ allowedTools: cfg.allowedTools,
124
+ escAllowedTools: cfg.escAllowedTools,
125
+ spawnAllowedTools: cfg.spawnAllowedTools,
126
+ autoApproveTools: cfg.autoApproveTools,
127
+ default: cfg.default,
128
+ };
129
+ const expand = (s?: string): string | undefined => {
130
+ if (!s) return s;
131
+ return s.replace(VAR_REF_RE, (m, name: string) => {
132
+ const ref = refTargets[name];
133
+ // Only substitute when the referenced field is a top-level field
134
+ // *that has a value*. Otherwise leave the placeholder untouched so
135
+ // the env-var pass can still pick it up (e.g. ${HOME}).
136
+ return ref !== undefined ? ref : m;
137
+ });
138
+ };
139
+ cfg.escAllowedTools = expand(cfg.escAllowedTools);
140
+ cfg.spawnAllowedTools = expand(cfg.spawnAllowedTools);
141
+ }
142
+
143
+ function candidatePaths(): string[] {
144
+ const explicit = process.env.AGENTS_CONFIG_PATH;
145
+ if (explicit) return [resolve(explicit)];
146
+ // ~/.sinain/agents.json is the wizard's write target — works on npm
147
+ // installs where the package dir is read-only. Highest priority after
148
+ // an explicit env override so user-home config wins over the shipped
149
+ // template.
150
+ const userHome = resolve(homedir(), ".sinain", "agents.json");
151
+ // sinain-core compiled output sits at sinain-core/dist/, source at
152
+ // sinain-core/src/. From either, sinain-agent is two levels up.
153
+ const repoRoot = resolve(__dirname, "..", "..");
154
+ return [
155
+ userHome,
156
+ resolve(repoRoot, "sinain-agent", "agents.json"),
157
+ resolve(repoRoot, "sinain-agent", "agents.example.json"),
158
+ ];
159
+ }
160
+
161
+ /**
162
+ * Load agents.json (or fall back to agents.example.json). Returns null if
163
+ * neither candidate file exists or parsing fails. Caller (config.ts) is
164
+ * expected to treat null as "use env defaults" so the migration is
165
+ * non-breaking.
166
+ */
167
+ export function loadAgentsConfig(): AgentsConfig | null {
168
+ for (const path of candidatePaths()) {
169
+ if (!existsSync(path)) continue;
170
+ try {
171
+ const raw = readFileSync(path, "utf-8");
172
+ const parsed = JSON.parse(raw) as AgentsConfig;
173
+ // Pass 1: cross-field literal substitution at top level. Has to run
174
+ // before env-var expansion so `${allowedTools}` doesn't accidentally
175
+ // resolve to process.env.allowedTools (which doesn't exist anyway,
176
+ // but the principle of layered substitution is what we want).
177
+ expandTopLevelRefs(parsed);
178
+ // Pass 2: ${ENV_VAR} expansion across the whole tree. Profile-level
179
+ // env blocks are deliberately skipped here — those are applied by
180
+ // run.sh in a subshell at invocation time, not by sinain-core.
181
+ const profiles = parsed.profiles;
182
+ delete parsed.profiles;
183
+ const expanded = expandEnvDeep(parsed);
184
+ // Restore profiles (with their own env blocks expanded for the few
185
+ // openclaw-specific fields that sinain-core does read).
186
+ if (profiles) {
187
+ const expandedProfiles: Record<string, AgentProfile> = {};
188
+ for (const [name, profile] of Object.entries(profiles)) {
189
+ // Don't expand the per-profile `env` block — those are evaluated
190
+ // at agent invocation time inside run.sh. But DO expand other
191
+ // profile fields (wsToken, etc.) since sinain-core reads them.
192
+ const { env: profileEnv, ...rest } = profile;
193
+ expandedProfiles[name] = {
194
+ ...expandEnvDeep(rest),
195
+ ...(profileEnv !== undefined ? { env: profileEnv } : {}),
196
+ };
197
+ }
198
+ expanded.profiles = expandedProfiles;
199
+ }
200
+ expanded.loadedFrom = path;
201
+ return expanded;
202
+ } catch (err) {
203
+ console.warn(`[agents-loader] failed to parse ${path}: ${(err as Error).message}`);
204
+ }
205
+ }
206
+ return null;
207
+ }
208
+
209
+ /** Convenience: pull the openclaw profile if present, else null.
210
+ * @deprecated Prefer findGatewayProfile() — it doesn't assume the name. */
211
+ export function getOpenClawProfile(cfg: AgentsConfig | null): AgentProfile | null {
212
+ return cfg?.profiles?.openclaw ?? null;
213
+ }
214
+
215
+ /**
216
+ * Find a gateway-style profile (any profile with `type: "openclaw"`,
217
+ * regardless of the profile's name). Used by sinain-core to:
218
+ * - load WS connection params at startup (config.ts)
219
+ * - decide whether a lane choice should route via WS or HTTP (escalator.ts)
220
+ *
221
+ * Preference order: the canonical name "openclaw" wins if present, otherwise
222
+ * the first openclaw-typed profile found in the JSON. This lets users add
223
+ * `nemoclaw`, `nanoclaw-prod`, etc. with their own URLs as drop-in replacements.
224
+ */
225
+ export function findGatewayProfile(
226
+ cfg: AgentsConfig | null,
227
+ ): { name: string; profile: AgentProfile } | null {
228
+ const profiles = cfg?.profiles;
229
+ if (!profiles) return null;
230
+ if (profiles.openclaw?.type === "openclaw") {
231
+ return { name: "openclaw", profile: profiles.openclaw };
232
+ }
233
+ for (const [name, profile] of Object.entries(profiles)) {
234
+ if (profile?.type === "openclaw") return { name, profile };
235
+ }
236
+ return null;
237
+ }
238
+
239
+ /** Is the given profile name a gateway-style (WS-dispatched) profile? */
240
+ export function isGatewayProfile(
241
+ cfg: AgentsConfig | null,
242
+ name: string,
243
+ ): boolean {
244
+ return cfg?.profiles?.[name]?.type === "openclaw";
245
+ }
246
+
247
+ /** All openclaw-typed profile names in the config. */
248
+ export function gatewayProfileNames(cfg: AgentsConfig | null): string[] {
249
+ const profiles = cfg?.profiles;
250
+ if (!profiles) return [];
251
+ return Object.entries(profiles)
252
+ .filter(([_, p]) => p?.type === "openclaw")
253
+ .map(([name]) => name);
254
+ }
@@ -48,12 +48,14 @@ export class FeedBuffer {
48
48
  this.items.push(item);
49
49
  if (this.items.length > this._hwm) this._hwm = this.items.length;
50
50
 
51
- // Fire onFull when buffer is at capacity AND enough new items have arrived
52
- // since the last distillation (at least half the buffer replaced)
51
+ // Fire when enough new items have arrived since last distillation.
52
+ // 20 items 1.7 min of audio at ~12 items/min transcription rate.
53
+ // Distillation takes ~7s, so 20-item threshold gives 100s gap — safe margin.
54
+ // This means ~35 passes/hour, leaving <20 items undistilled at shutdown.
53
55
  const newSinceRearm = this._version - this._onFullVersion;
54
- if (this.items.length >= this.maxSize
56
+ if (this.items.length >= 20
55
57
  && this._onFullCb && this._onFullArmed
56
- && newSinceRearm >= Math.floor(this.maxSize / 2)) {
58
+ && newSinceRearm >= 20) {
57
59
  this._onFullArmed = false;
58
60
  const snapshot = [...this.items];
59
61
  queueMicrotask(() => this._onFullCb!(snapshot));
@@ -2,8 +2,9 @@ import { readFileSync, existsSync } from "node:fs";
2
2
  import { resolve, dirname } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import os from "node:os";
5
- import type { CoreConfig, AudioPipelineConfig, TranscriptionConfig, AnalysisConfig, EscalationConfig, OpenClawConfig, EscalationMode, EscalationTransport, LearningConfig, PrivacyConfig, PrivacyMatrix, PrivacyLevel, PrivacyRow } from "./types.js";
5
+ import type { CoreConfig, AudioPipelineConfig, TranscriptionConfig, AnalysisConfig, EscalationConfig, OpenClawConfig, EscalationMode, LearningConfig, PrivacyConfig, PrivacyMatrix, PrivacyLevel, PrivacyRow } from "./types.js";
6
6
  import { PRESETS } from "./privacy/presets.js";
7
+ import { loadAgentsConfig, findGatewayProfile, type AgentsConfig } from "./agents-loader.js";
7
8
 
8
9
  const __dirname = dirname(fileURLToPath(import.meta.url));
9
10
 
@@ -140,6 +141,26 @@ function loadPrivacyConfig(): PrivacyConfig {
140
141
  }
141
142
 
142
143
  export function loadConfig(): CoreConfig {
144
+ // sinain-agent/agents.json is the new single source of truth for the
145
+ // bare-agent + OpenClaw config that used to live in .env. Loading
146
+ // happens once at startup; null means the file isn't there (fresh
147
+ // checkout, custom layout, etc.) and we fall back to env defaults.
148
+ const agentsCfg = loadAgentsConfig();
149
+ if (agentsCfg) {
150
+ console.log(`[config] loaded agents config from ${agentsCfg.loadedFrom}`);
151
+ }
152
+
153
+ // Helpers that prefer agents.json values, then env, then a hardcoded
154
+ // default. Keeps .env working as a safety net during the migration.
155
+ const fromCfgStr = (cfgVal: string | undefined, envKey: string, def: string): string => {
156
+ if (cfgVal !== undefined && cfgVal !== "") return cfgVal;
157
+ return env(envKey, def);
158
+ };
159
+ const fromCfgInt = (cfgVal: number | undefined, envKey: string, def: number): number => {
160
+ if (cfgVal !== undefined) return cfgVal;
161
+ return intEnv(envKey, def);
162
+ };
163
+
143
164
  const audioConfig: AudioPipelineConfig = {
144
165
  device: env("AUDIO_DEVICE", "BlackHole 2ch"),
145
166
  sampleRate: intEnv("AUDIO_SAMPLE_RATE", 16000),
@@ -196,30 +217,57 @@ export function loadConfig(): CoreConfig {
196
217
  .split(",").map(s => s.trim()).filter(Boolean),
197
218
  timeout: intEnv("ANALYSIS_TIMEOUT", 15000),
198
219
  pushToFeed: boolEnv("AGENT_PUSH_TO_FEED", true),
199
- debounceMs: intEnv("AGENT_DEBOUNCE_MS", 3000),
200
- maxIntervalMs: intEnv("AGENT_MAX_INTERVAL_MS", 30000),
220
+ // analyzer pacing: agents.json `analyzer` block, fall back to env
221
+ debounceMs: fromCfgInt(agentsCfg?.analyzer?.debounceMs, "AGENT_DEBOUNCE_MS", 3000),
222
+ maxIntervalMs: fromCfgInt(agentsCfg?.analyzer?.maxIntervalMs, "AGENT_MAX_INTERVAL_MS", 30000),
201
223
  cooldownMs: intEnv("AGENT_COOLDOWN_MS", 10000),
202
224
  maxAgeMs: intEnv("AGENT_MAX_AGE_MS", 120000),
203
225
  historyLimit: intEnv("AGENT_HISTORY_LIMIT", 50),
204
226
  };
205
227
 
206
- const escalationMode = env("ESCALATION_MODE", "rich") as EscalationMode;
228
+ // escalation policy: agents.json `escalation` block, fall back to env.
229
+ // Mode is runtime-mutable via the overlay's flash-icon selector; this only
230
+ // sets the boot-time default. (Transport is no longer a setting — per-lane
231
+ // agent choice in the overlay determines WS vs HTTP dispatch.)
232
+ const escalationMode = fromCfgStr(agentsCfg?.escalation?.mode, "ESCALATION_MODE", "rich") as EscalationMode;
207
233
  const escalationConfig: EscalationConfig = {
208
234
  mode: escalationMode,
209
- cooldownMs: intEnv("ESCALATION_COOLDOWN_MS", 30000),
210
- staleMs: intEnv("ESCALATION_STALE_MS", 90000),
211
- transport: env("ESCALATION_TRANSPORT", "auto") as EscalationTransport,
235
+ cooldownMs: fromCfgInt(agentsCfg?.escalation?.cooldownMs, "ESCALATION_COOLDOWN_MS", 30000),
236
+ staleMs: fromCfgInt(agentsCfg?.escalation?.staleMs, "ESCALATION_STALE_MS", 90000),
212
237
  };
213
238
 
239
+ // OpenClaw gateway config: pick the first openclaw-TYPED profile
240
+ // (preferring the canonical name "openclaw"). This lets users add
241
+ // server-side variants — `nemoclaw`, `nanoclaw-prod`, etc. — as full
242
+ // peers; the WS client connects to whichever shows up first in the
243
+ // profiles list. Removing all openclaw-typed profiles (and unsetting
244
+ // the env vars) leaves gatewayWsUrl empty → registerBareAgent skips
245
+ // injecting any gateway entry into the roster, and escalator.start()
246
+ // skips the WS connect.
247
+ //
248
+ // Limitation: only ONE gateway URL gets a live WS client today. Adding
249
+ // multiple openclaw-typed profiles with different URLs lets the user
250
+ // pick them in the overlay roster, but dispatch always uses the first
251
+ // one's connection params. True per-profile WS clients is a follow-up.
252
+ const gateway = findGatewayProfile(agentsCfg);
253
+ const openclawProfile = gateway?.profile;
254
+ // wsUrl/hookUrl default to empty so "delete the openclaw profile" genuinely
255
+ // disables the gateway. The shipped agents.example.json includes the
256
+ // profile with localhost defaults, so fresh installs still work out of
257
+ // the box; removal is an explicit opt-out.
214
258
  const openclawConfig: OpenClawConfig = {
215
- gatewayWsUrl: envAllowEmpty("OPENCLAW_WS_URL", "OPENCLAW_GATEWAY_WS_URL", "ws://localhost:18789"),
216
- gatewayToken: env("OPENCLAW_WS_TOKEN", env("OPENCLAW_GATEWAY_TOKEN", "")),
217
- hookUrl: envAllowEmpty("OPENCLAW_HTTP_URL", "OPENCLAW_HOOK_URL", "http://localhost:18789/hooks/agent"),
218
- hookToken: env("OPENCLAW_HTTP_TOKEN", env("OPENCLAW_HOOK_TOKEN", "")),
219
- sessionKey: env("OPENCLAW_SESSION_KEY", "agent:main:sinain"),
220
- phase1TimeoutMs: intEnv("OPENCLAW_PHASE1_TIMEOUT_MS", 30_000),
221
- phase2TimeoutMs: intEnv("OPENCLAW_PHASE2_TIMEOUT_MS", 120_000),
222
- pingIntervalMs: intEnv("OPENCLAW_PING_INTERVAL_MS", 30_000),
259
+ gatewayWsUrl: fromCfgStr(openclawProfile?.wsUrl, "OPENCLAW_WS_URL",
260
+ envAllowEmpty("OPENCLAW_GATEWAY_WS_URL", undefined, "")),
261
+ gatewayToken: fromCfgStr(openclawProfile?.wsToken, "OPENCLAW_WS_TOKEN",
262
+ env("OPENCLAW_GATEWAY_TOKEN", "")),
263
+ hookUrl: fromCfgStr(openclawProfile?.httpUrl, "OPENCLAW_HTTP_URL",
264
+ envAllowEmpty("OPENCLAW_HOOK_URL", undefined, "")),
265
+ hookToken: fromCfgStr(openclawProfile?.httpToken, "OPENCLAW_HTTP_TOKEN",
266
+ env("OPENCLAW_HOOK_TOKEN", "")),
267
+ sessionKey: fromCfgStr(openclawProfile?.sessionKey, "OPENCLAW_SESSION_KEY", "agent:main:sinain"),
268
+ phase1TimeoutMs: fromCfgInt(openclawProfile?.phase1TimeoutMs, "OPENCLAW_PHASE1_TIMEOUT_MS", 30_000),
269
+ phase2TimeoutMs: fromCfgInt(openclawProfile?.phase2TimeoutMs, "OPENCLAW_PHASE2_TIMEOUT_MS", 120_000),
270
+ pingIntervalMs: fromCfgInt(openclawProfile?.pingIntervalMs, "OPENCLAW_PING_INTERVAL_MS", 30_000),
223
271
  };
224
272
 
225
273
  const situationDir = env("OPENCLAW_WORKSPACE_DIR", "~/.openclaw/workspace");
@@ -236,6 +284,19 @@ export function loadConfig(): CoreConfig {
236
284
 
237
285
  const privacyConfig = loadPrivacyConfig();
238
286
 
287
+ // autoApproveTools: agents.json top-level field, falls back to
288
+ // SINAIN_AUTO_APPROVE_TOOLS env. Space-separated tool names or prefix
289
+ // patterns (suffix *) that sinain-core auto-approves at /spawn/approve
290
+ // without routing to the overlay.
291
+ const autoApproveRaw = fromCfgStr(
292
+ agentsCfg?.autoApproveTools,
293
+ "SINAIN_AUTO_APPROVE_TOOLS",
294
+ "Read Glob Grep Ls Cat mcp__sinain*",
295
+ );
296
+ const permissionsConfig = {
297
+ autoApproveTools: autoApproveRaw.split(/\s+/).filter((t) => t.length > 0),
298
+ };
299
+
239
300
  return {
240
301
  port: intEnv("PORT", 9500),
241
302
  audioConfig,
@@ -251,5 +312,6 @@ export function loadConfig(): CoreConfig {
251
312
  costDisplayEnabled: boolEnv("COST_DISPLAY_ENABLED", false),
252
313
  learningConfig,
253
314
  privacyConfig,
315
+ permissionsConfig,
254
316
  };
255
317
  }