@2en/clawly-plugins 1.30.0-beta.3 → 1.30.0-beta.4

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.
@@ -12,6 +12,13 @@
12
12
  apiKey: "${CLAWLY_MODEL_GATEWAY_API_KEY}",
13
13
  api: "openai-completions",
14
14
  models: [
15
+ {
16
+ id: "auto",
17
+ name: "auto",
18
+ input: ["text", "image"],
19
+ contextWindow: 262144,
20
+ maxTokens: 65535,
21
+ },
15
22
  {
16
23
  id: "moonshotai/kimi-k2.5",
17
24
  name: "moonshotai/kimi-k2.5",
@@ -33,6 +40,13 @@
33
40
  contextWindow: 1048576,
34
41
  maxTokens: 65536,
35
42
  },
43
+ {
44
+ id: "google/gemini-3.1-pro-preview",
45
+ name: "google/gemini-3.1-pro-preview",
46
+ input: ["text", "image"],
47
+ contextWindow: 1048576,
48
+ maxTokens: 65536,
49
+ },
36
50
  {
37
51
  id: "anthropic/claude-sonnet-4.6",
38
52
  name: "anthropic/claude-sonnet-4.6",
@@ -89,12 +103,12 @@
89
103
  },
90
104
  },
91
105
 
92
- // agents.defaults.modelswhich models the agent can use, display names,
93
- // and per-model overrides. The whitelist only constrains the UI model picker
94
- // and fallback lists.
106
+ // agents.defaults — model picker whitelist, display names, and tuning defaults.
107
+ // The whitelist constrains the UI model picker and fallback lists.
95
108
  agents: {
96
109
  defaults: {
97
110
  thinkingDefault: "off",
111
+ verboseDefault: "on",
98
112
  model: {
99
113
  primary: "clawly-model-gateway/anthropic/claude-sonnet-4.6",
100
114
  },
@@ -103,9 +117,11 @@
103
117
  fallbacks: [],
104
118
  },
105
119
  models: {
120
+ "clawly-model-gateway/auto": { alias: "Auto" },
106
121
  "clawly-model-gateway/moonshotai/kimi-k2.5": { alias: "Kimi K2.5" },
107
122
  "clawly-model-gateway/google/gemini-2.5-pro": { alias: "Gemini 2.5 Pro" },
108
123
  "clawly-model-gateway/google/gemini-3-pro-preview": { alias: "Gemini 3 Pro Preview" },
124
+ "clawly-model-gateway/google/gemini-3.1-pro-preview": { alias: "Gemini 3.1 Pro Preview" },
109
125
  "clawly-model-gateway/anthropic/claude-sonnet-4.6": { alias: "Claude Sonnet 4.6" },
110
126
  "clawly-model-gateway/anthropic/claude-opus-4.6": { alias: "Claude Opus 4.6" },
111
127
  "clawly-model-gateway/openai/gpt-5.4": { alias: "GPT-5.4" },
@@ -114,6 +130,94 @@
114
130
  "clawly-model-gateway/qwen/qwen3.5-plus-02-15": { alias: "Qwen 3.5 Plus" },
115
131
  "clawly-model-gateway/z-ai/glm-5": { alias: "GLM-5" },
116
132
  },
133
+ contextPruning: {
134
+ mode: "cache-ttl",
135
+ },
136
+ heartbeat: {
137
+ model: "clawly-model-gateway/moonshotai/kimi-k2.5",
138
+ lightContext: true,
139
+ },
140
+ memorySearch: {
141
+ provider: "openai",
142
+ model: "text-embedding-3-small",
143
+ remote: {
144
+ baseUrl: "${CLAWLY_MODEL_GATEWAY_BASE}/v1",
145
+ apiKey: "${CLAWLY_MODEL_GATEWAY_API_KEY}",
146
+ batch: { enabled: false },
147
+ },
148
+ },
149
+ },
150
+ },
151
+
152
+ // messages.tts — text-to-speech credentials + tuning defaults
153
+ messages: {
154
+ tts: {
155
+ auto: "tagged",
156
+ mode: "final",
157
+ provider: "elevenlabs",
158
+ summaryModel: "clawly-model-gateway/anthropic/claude-sonnet-4.6",
159
+ maxTextLength: 4000,
160
+ timeoutMs: 30000,
161
+ prefsPath: "~/.openclaw/settings/tts.json",
162
+ modelOverrides: { enabled: true },
163
+ elevenlabs: {
164
+ baseUrl: "${CLAWLY_MODEL_GATEWAY_BASE}/v1/elevenlabs",
165
+ apiKey: "${CLAWLY_MODEL_GATEWAY_API_KEY}",
166
+ voiceId: "DwwuoY7Uz8AP8zrY5TAo",
167
+ modelId: "eleven_multilingual_v2",
168
+ seed: 42,
169
+ applyTextNormalization: "auto",
170
+ languageCode: "en",
171
+ voiceSettings: {
172
+ stability: 0.5,
173
+ similarityBoost: 0.75,
174
+ style: 0.0,
175
+ useSpeakerBoost: true,
176
+ speed: 1.0,
177
+ },
178
+ },
179
+ },
180
+ },
181
+
182
+ // gateway.nodes — allowlist dangerous node commands so the AI agent can
183
+ // invoke them on connected Clawly nodes (browser, reminders, calendar, device).
184
+ gateway: {
185
+ nodes: {
186
+ allowCommands: [
187
+ // Browser commands (Mac nodes)
188
+ "browser.proxy",
189
+ "browser.navigate",
190
+ "browser.click",
191
+ "browser.type",
192
+ "browser.screenshot",
193
+ "browser.read",
194
+ "browser.tabs",
195
+ "browser.back",
196
+ "browser.scroll",
197
+ "browser.evaluate",
198
+ // Reminders + calendar (iOS nodes)
199
+ "reminders.add",
200
+ "calendar.add",
201
+ // Device permissions (iOS nodes)
202
+ "device.permissions",
203
+ "device.requestPermission",
204
+ ],
205
+ },
206
+ },
207
+
208
+ // tools.web.search — web search defaults
209
+ tools: {
210
+ web: {
211
+ search: {
212
+ provider: "perplexity",
213
+ perplexity: {
214
+ model: "perplexity/sonar-pro",
215
+ baseUrl: "${CLAWLY_MODEL_GATEWAY_BASE}/v1",
216
+ apiKey: "${CLAWLY_MODEL_GATEWAY_API_KEY}",
217
+ },
218
+ maxResults: 5,
219
+ timeoutSeconds: 30,
220
+ },
117
221
  },
118
222
  },
119
223
  }
package/config-setup.ts CHANGED
@@ -17,10 +17,9 @@
17
17
 
18
18
  import fs from 'node:fs'
19
19
  import path from 'node:path'
20
-
21
- import type {PluginApi} from './index'
20
+ import JSON5 from 'json5'
22
21
  import {autoSanitizeSession} from './gateway/session-sanitize'
23
- import {resolveGatewayCredentials} from './resolve-gateway-credentials'
22
+ import type {PluginApi} from './index'
24
23
  import {
25
24
  ENV_KEY_API_KEY,
26
25
  ENV_KEY_BASE,
@@ -30,16 +29,14 @@ import {
30
29
  stripPathname,
31
30
  writeOpenclawConfig,
32
31
  } from './model-gateway-setup'
32
+ import {resolveGatewayCredentials} from './resolve-gateway-credentials'
33
+ import type {OpenClawConfig} from './types/openclaw'
33
34
 
34
35
  export interface ConfigPluginConfig {
35
36
  instanceId?: string
36
37
  agentId?: string
37
38
  agentName?: string
38
39
  workspaceDir?: string
39
- defaultModel?: string
40
- defaultImageModel?: string
41
- defaultHeartbeatModel?: string
42
- elevenlabsApiKey?: string
43
40
  modelGatewayBaseUrl?: string
44
41
  modelGatewayToken?: string
45
42
  otelToken?: string
@@ -54,7 +51,7 @@ function asObj(value: unknown): Record<string, unknown> {
54
51
  : {}
55
52
  }
56
53
 
57
- function readFilePluginConfig(config: Record<string, unknown>): ConfigPluginConfig {
54
+ function readFilePluginConfig(config: OpenClawConfig): ConfigPluginConfig {
58
55
  const plugins = asObj(config.plugins)
59
56
  const entries = asObj(plugins.entries)
60
57
  const raw = asObj(entries['clawly-plugins'])
@@ -77,25 +74,12 @@ function mergeDefinedPluginConfig(
77
74
  return merged
78
75
  }
79
76
 
80
- function toPCMerged(api: PluginApi, config: Record<string, unknown>): ConfigPluginConfig {
77
+ function toPCMerged(api: PluginApi, config: OpenClawConfig): ConfigPluginConfig {
81
78
  const fileConfig = readFilePluginConfig(config)
82
79
  const runtimeConfig = (api.pluginConfig ?? {}) as ConfigPluginConfig
83
80
  return mergeDefinedPluginConfig(fileConfig, runtimeConfig)
84
81
  }
85
82
 
86
- const LEGACY_DEFAULT_MODEL = `${PROVIDER_NAME}/anthropic/claude-sonnet-4.6`
87
- const DEFAULT_ELEVENLABS_VOICE_ID = 'DwwuoY7Uz8AP8zrY5TAo'
88
-
89
- function toProviderModelId(model?: string): string {
90
- const normalized = model?.trim() ?? ''
91
- if (!normalized) return ''
92
- return normalized.startsWith(`${PROVIDER_NAME}/`) ? normalized : `${PROVIDER_NAME}/${normalized}`
93
- }
94
-
95
- function resolveDefaultModel(pc: ConfigPluginConfig): string {
96
- return toProviderModelId(pc.defaultModel) || LEGACY_DEFAULT_MODEL
97
- }
98
-
99
83
  // ---------------------------------------------------------------------------
100
84
  // Domain helpers — each returns true when config was mutated
101
85
  //
@@ -104,7 +88,7 @@ function resolveDefaultModel(pc: ConfigPluginConfig): string {
104
88
  // diagnostic UI stays in sync.
105
89
  // ---------------------------------------------------------------------------
106
90
 
107
- export function patchAgent(config: Record<string, unknown>, pc: ConfigPluginConfig): boolean {
91
+ export function patchAgent(config: OpenClawConfig, pc: ConfigPluginConfig): boolean {
108
92
  if (!pc.agentId) return false
109
93
 
110
94
  let dirty = false
@@ -138,22 +122,6 @@ export function patchAgent(config: Record<string, unknown>, pc: ConfigPluginConf
138
122
  dirty = true
139
123
  }
140
124
 
141
- // model: set-if-missing
142
- if (!defaults.model) {
143
- defaults.model = {primary: resolveDefaultModel(pc)}
144
- dirty = true
145
- }
146
-
147
- // imageModel: set-if-missing
148
- const defaultImageModel = toProviderModelId(pc.defaultImageModel)
149
- if (defaultImageModel && !defaults.imageModel) {
150
- defaults.imageModel = {
151
- primary: defaultImageModel,
152
- fallbacks: [],
153
- }
154
- dirty = true
155
- }
156
-
157
125
  // sandbox.mode: enforce (only set the field we care about)
158
126
  const sandbox = (defaults.sandbox ?? {}) as Record<string, unknown>
159
127
  if (sandbox.mode !== 'off') {
@@ -162,12 +130,6 @@ export function patchAgent(config: Record<string, unknown>, pc: ConfigPluginConf
162
130
  dirty = true
163
131
  }
164
132
 
165
- // verboseDefault: set-if-missing
166
- if (defaults.verboseDefault === undefined) {
167
- defaults.verboseDefault = 'on'
168
- dirty = true
169
- }
170
-
171
133
  if (dirty) {
172
134
  agents.defaults = defaults
173
135
  config.agents = agents
@@ -176,7 +138,7 @@ export function patchAgent(config: Record<string, unknown>, pc: ConfigPluginConf
176
138
  return dirty
177
139
  }
178
140
 
179
- export function patchGateway(config: Record<string, unknown>): boolean {
141
+ export function patchGateway(config: OpenClawConfig): boolean {
180
142
  let dirty = false
181
143
  const gateway = (config.gateway ?? {}) as Record<string, unknown>
182
144
 
@@ -223,7 +185,7 @@ export function patchGateway(config: Record<string, unknown>): boolean {
223
185
  return dirty
224
186
  }
225
187
 
226
- export function patchBrowser(config: Record<string, unknown>): boolean {
188
+ export function patchBrowser(config: OpenClawConfig): boolean {
227
189
  let dirty = false
228
190
 
229
191
  // browser: enforce individual fields only
@@ -250,7 +212,7 @@ export function patchBrowser(config: Record<string, unknown>): boolean {
250
212
  dirty = true
251
213
  }
252
214
 
253
- // owner: grant mobile/mac app owner permissions (required for cron tool etc.)
215
+ // ownerAllowFrom: append-if-missing (can't use $include arrays concatenate)
254
216
  const ownerAllowFrom = Array.isArray(commands.ownerAllowFrom)
255
217
  ? (commands.ownerAllowFrom as string[])
256
218
  : []
@@ -270,264 +232,10 @@ export function patchBrowser(config: Record<string, unknown>): boolean {
270
232
  return dirty
271
233
  }
272
234
 
273
- export function patchTts(config: Record<string, unknown>, pc: ConfigPluginConfig): boolean {
274
- let dirty = false
275
- const messages = (config.messages ?? {}) as Record<string, unknown>
276
- const tts = (messages.tts ?? {}) as Record<string, unknown>
277
- const elevenlabs = (tts.elevenlabs ?? {}) as Record<string, unknown>
278
-
279
- // Credentials: enforce (only when credentials are available)
280
- const useProxy = !!(pc.modelGatewayBaseUrl && pc.modelGatewayToken)
281
- const hasDirectKey = !!pc.elevenlabsApiKey
282
- if (useProxy || hasDirectKey) {
283
- const targetApiKey = useProxy ? pc.modelGatewayToken! : pc.elevenlabsApiKey!
284
- const targetBaseUrl = useProxy
285
- ? `${pc.modelGatewayBaseUrl}/elevenlabs`
286
- : 'https://api.elevenlabs.io'
287
-
288
- if (elevenlabs.apiKey !== targetApiKey) {
289
- elevenlabs.apiKey = targetApiKey
290
- dirty = true
291
- }
292
- if (elevenlabs.baseUrl !== targetBaseUrl) {
293
- elevenlabs.baseUrl = targetBaseUrl
294
- dirty = true
295
- }
296
- }
297
-
298
- // voiceId: set-if-missing (never overwrite user selection)
299
- // Use falsy check — old plugin versions (< beta.3) enforced voiceId = ''
300
- // which is not undefined but is still an empty/invalid value.
301
- // null is also treated as missing here.
302
- if (!elevenlabs.voiceId) {
303
- elevenlabs.voiceId = DEFAULT_ELEVENLABS_VOICE_ID
304
- dirty = true
305
- }
306
-
307
- // Tuning: set-if-missing
308
- if (tts.auto === undefined) {
309
- tts.auto = 'tagged'
310
- dirty = true
311
- }
312
- if (tts.mode === undefined) {
313
- tts.mode = 'final'
314
- dirty = true
315
- }
316
- if (tts.provider === undefined) {
317
- tts.provider = 'elevenlabs'
318
- dirty = true
319
- }
320
- if (tts.summaryModel === undefined) {
321
- tts.summaryModel = resolveDefaultModel(pc)
322
- dirty = true
323
- }
324
- if (tts.modelOverrides === undefined) {
325
- tts.modelOverrides = {enabled: true}
326
- dirty = true
327
- }
328
- if (tts.maxTextLength === undefined) {
329
- tts.maxTextLength = 4000
330
- dirty = true
331
- }
332
- if (tts.timeoutMs === undefined) {
333
- tts.timeoutMs = 30000
334
- dirty = true
335
- }
336
- if (tts.prefsPath === undefined) {
337
- tts.prefsPath = '~/.openclaw/settings/tts.json'
338
- dirty = true
339
- }
340
-
341
- // elevenlabs tuning: set-if-missing
342
- if (elevenlabs.modelId === undefined) {
343
- elevenlabs.modelId = 'eleven_multilingual_v2'
344
- dirty = true
345
- }
346
- if (elevenlabs.seed === undefined) {
347
- elevenlabs.seed = 42
348
- dirty = true
349
- }
350
- if (elevenlabs.applyTextNormalization === undefined) {
351
- elevenlabs.applyTextNormalization = 'auto'
352
- dirty = true
353
- }
354
- if (elevenlabs.languageCode === undefined) {
355
- elevenlabs.languageCode = 'en'
356
- dirty = true
357
- }
358
- if (elevenlabs.voiceSettings === undefined) {
359
- elevenlabs.voiceSettings = {
360
- stability: 0.5,
361
- similarityBoost: 0.75,
362
- style: 0.0,
363
- useSpeakerBoost: true,
364
- speed: 1.0,
365
- }
366
- dirty = true
367
- }
368
-
369
- if (dirty) {
370
- tts.elevenlabs = elevenlabs
371
- messages.tts = tts
372
- config.messages = messages
373
- }
374
-
375
- return dirty
376
- }
377
-
378
- export function patchWebSearch(config: Record<string, unknown>, pc: ConfigPluginConfig): boolean {
379
- if (!pc.modelGatewayBaseUrl || !pc.modelGatewayToken) return false
380
-
381
- let dirty = false
382
- const tools = (config.tools ?? {}) as Record<string, unknown>
383
- const web = (tools.web ?? {}) as Record<string, unknown>
384
- const search = (web.search ?? {}) as Record<string, unknown>
385
- const perplexity = (search.perplexity ?? {}) as Record<string, unknown>
386
-
387
- // Provider: enforce
388
- if (search.provider !== 'perplexity') {
389
- search.provider = 'perplexity'
390
- dirty = true
391
- }
392
-
393
- // Perplexity credentials: enforce (nested under search.perplexity)
394
- if (perplexity.apiKey !== pc.modelGatewayToken) {
395
- perplexity.apiKey = pc.modelGatewayToken
396
- dirty = true
397
- }
398
- if (perplexity.baseUrl !== pc.modelGatewayBaseUrl) {
399
- perplexity.baseUrl = pc.modelGatewayBaseUrl
400
- dirty = true
401
- }
402
-
403
- // Perplexity model: set-if-missing
404
- if (perplexity.model === undefined) {
405
- perplexity.model = 'perplexity/sonar-pro'
406
- dirty = true
407
- }
408
-
409
- // Tuning: set-if-missing
410
- if (search.maxResults === undefined) {
411
- search.maxResults = 5
412
- dirty = true
413
- }
414
- if (search.timeoutSeconds === undefined) {
415
- search.timeoutSeconds = 30
416
- dirty = true
417
- }
418
-
419
- if (dirty) {
420
- search.perplexity = perplexity
421
- web.search = search
422
- tools.web = web
423
- config.tools = tools
424
- }
425
-
426
- return dirty
427
- }
428
-
429
- export function patchMemorySearch(
430
- config: Record<string, unknown>,
431
- pc: ConfigPluginConfig,
432
- ): boolean {
433
- if (!pc.modelGatewayBaseUrl || !pc.modelGatewayToken) return false
434
-
435
- let dirty = false
436
- const agents = (config.agents ?? {}) as Record<string, unknown>
437
- const defaults = (agents.defaults ?? {}) as Record<string, unknown>
438
- const ms = (defaults.memorySearch ?? {}) as Record<string, unknown>
439
- const remote = (ms.remote ?? {}) as Record<string, unknown>
440
- const batch = (remote.batch ?? {}) as Record<string, unknown>
441
-
442
- // Provider: enforce (proxy mimics OpenAI API)
443
- if (ms.provider !== 'openai') {
444
- ms.provider = 'openai'
445
- dirty = true
446
- }
447
-
448
- // Model: set-if-missing
449
- if (ms.model === undefined) {
450
- ms.model = 'text-embedding-3-small'
451
- dirty = true
452
- }
453
-
454
- // Remote credentials: enforce
455
- if (remote.baseUrl !== pc.modelGatewayBaseUrl) {
456
- remote.baseUrl = pc.modelGatewayBaseUrl
457
- dirty = true
458
- }
459
- if (remote.apiKey !== pc.modelGatewayToken) {
460
- remote.apiKey = pc.modelGatewayToken
461
- dirty = true
462
- }
463
-
464
- // Batch API not supported through proxy: enforce disabled
465
- if (batch.enabled !== false) {
466
- batch.enabled = false
467
- dirty = true
468
- }
469
-
470
- if (dirty) {
471
- remote.batch = batch
472
- ms.remote = remote
473
- defaults.memorySearch = ms
474
- agents.defaults = defaults
475
- config.agents = agents
476
- }
477
-
478
- return dirty
479
- }
480
-
481
- export function patchContextPruning(config: Record<string, unknown>): boolean {
482
- const agents = (config.agents ?? {}) as Record<string, unknown>
483
- const defaults = (agents.defaults ?? {}) as Record<string, unknown>
484
- const cp = (defaults.contextPruning ?? {}) as Record<string, unknown>
485
-
486
- // mode: set-if-missing — enable cache-ttl by default for cost savings,
487
- // but don't override if an operator has explicitly configured it.
488
- if (cp.mode === undefined) {
489
- cp.mode = 'cache-ttl'
490
- defaults.contextPruning = cp
491
- agents.defaults = defaults
492
- config.agents = agents
493
- return true
494
- }
495
-
496
- return false
497
- }
498
-
499
- const DEFAULT_HEARTBEAT_MODEL = `${PROVIDER_NAME}/moonshotai/kimi-k2.5`
500
-
501
- function resolveDefaultHeartbeatModel(pc: ConfigPluginConfig): string {
502
- return toProviderModelId(pc.defaultHeartbeatModel) || DEFAULT_HEARTBEAT_MODEL
503
- }
504
-
505
- export function patchHeartbeat(config: Record<string, unknown>, pc: ConfigPluginConfig): boolean {
506
- let dirty = false
507
- const agents = (config.agents ?? {}) as Record<string, unknown>
508
- const defaults = (agents.defaults ?? {}) as Record<string, unknown>
509
- const heartbeat = (defaults.heartbeat ?? {}) as Record<string, unknown>
510
-
511
- // model: set-if-missing
512
- if (!heartbeat.model) {
513
- heartbeat.model = resolveDefaultHeartbeatModel(pc)
514
- dirty = true
515
- }
516
-
517
- // lightContext: set-if-missing
518
- if (heartbeat.lightContext === undefined) {
519
- heartbeat.lightContext = true
520
- dirty = true
521
- }
522
-
523
- if (dirty) {
524
- defaults.heartbeat = heartbeat
525
- agents.defaults = defaults
526
- config.agents = agents
527
- }
528
-
529
- return dirty
530
- }
235
+ // patchTts migrated to clawly-config-defaults.json5
236
+ // patchWebSearch migrated to clawly-config-defaults.json5
237
+ // patchMemorySearch migrated to clawly-config-defaults.json5
238
+ // patchHeartbeat migrated to clawly-config-defaults.json5
531
239
 
532
240
  // TODO: Re-enable patchToolPolicy once rollback-safe (deny is sticky — rolling back
533
241
  // the plugin leaves web_search permanently denied). Tracked in ENG-1493.
@@ -552,7 +260,7 @@ export function patchHeartbeat(config: Record<string, unknown>, pc: ConfigPlugin
552
260
  // }
553
261
 
554
262
  export function patchEnvVars(
555
- config: import('./types/openclaw').OpenClawConfig,
263
+ config: OpenClawConfig,
556
264
  pc: ConfigPluginConfig,
557
265
  api: PluginApi,
558
266
  ): boolean {
@@ -589,7 +297,7 @@ export function patchEnvVars(
589
297
  return dirty
590
298
  }
591
299
 
592
- export function patchSession(config: Record<string, unknown>): boolean {
300
+ export function patchSession(config: OpenClawConfig): boolean {
593
301
  let dirty = false
594
302
 
595
303
  // Remove deprecated session.mainKey (OpenClaw v2026.1.5 hardcoded it to "main")
@@ -638,7 +346,7 @@ const INCLUDE_PATH = './extensions/clawly-plugins/clawly-config-defaults.json5'
638
346
  * Ensures our defaults file is listed in the config's `$include` array.
639
347
  * Returns true if `$include` was modified.
640
348
  */
641
- export function ensureInclude(config: Record<string, unknown>): boolean {
349
+ export function ensureInclude(config: OpenClawConfig & Record<string, unknown>): boolean {
642
350
  const existing = config.$include
643
351
  let includes: string[]
644
352
 
@@ -685,7 +393,7 @@ const NPM_PKG_NAME = '@2en/clawly-plugins'
685
393
  * in the provision write-plugins-config step. Without this record,
686
394
  * `openclaw plugins update` cannot function.
687
395
  */
688
- export function patchInstallRecord(config: Record<string, unknown>, stateDir: string): boolean {
396
+ export function patchInstallRecord(config: OpenClawConfig, stateDir: string): boolean {
689
397
  const plugins = asObj(config.plugins)
690
398
  const installs = asObj(plugins.installs)
691
399
 
@@ -734,11 +442,7 @@ export function patchInstallRecord(config: Record<string, unknown>, stateDir: st
734
442
  return true
735
443
  }
736
444
 
737
- function repairLegacyProvisionState(
738
- api: PluginApi,
739
- config: Record<string, unknown>,
740
- stateDir: string,
741
- ) {
445
+ function repairLegacyProvisionState(api: PluginApi, config: OpenClawConfig, stateDir: string) {
742
446
  const installRecordPatched = patchInstallRecord(config, stateDir)
743
447
  if (installRecordPatched) {
744
448
  api.logger.warn('plugins.installs.clawly-plugins was missing — self-healed from disk.')
@@ -746,10 +450,102 @@ function repairLegacyProvisionState(
746
450
  return installRecordPatched
747
451
  }
748
452
 
453
+ /**
454
+ * Remove fields from config that are identical to the included defaults.
455
+ * Since `$include` deep-merges with main config winning, residual values
456
+ * from old JS set-if-missing code "shadow" the json5 defaults and prevent
457
+ * future default updates from taking effect.
458
+ *
459
+ * Only prunes leaf values that exactly match.
460
+ * Returns true if any field was deleted.
461
+ */
462
+ function stableStringify(v: unknown): string {
463
+ if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
464
+ const obj = v as Record<string, unknown>
465
+ return `{${Object.keys(obj)
466
+ .sort()
467
+ .map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`)
468
+ .join(',')}}`
469
+ }
470
+ return JSON.stringify(v)
471
+ }
472
+
473
+ export function pruneIncludedDefaults(
474
+ config: Record<string, unknown>,
475
+ defaults: Record<string, unknown>,
476
+ ): boolean {
477
+ let pruned = false
478
+
479
+ function walk(defaultsNode: Record<string, unknown>, configNode: Record<string, unknown>): void {
480
+ for (const key of Object.keys(defaultsNode)) {
481
+ const defaultVal = defaultsNode[key]
482
+ const configVal = configNode[key]
483
+ if (configVal === undefined) continue
484
+
485
+ // Both are plain objects → recurse
486
+ if (
487
+ defaultVal !== null &&
488
+ typeof defaultVal === 'object' &&
489
+ !Array.isArray(defaultVal) &&
490
+ configVal !== null &&
491
+ typeof configVal === 'object' &&
492
+ !Array.isArray(configVal)
493
+ ) {
494
+ walk(defaultVal as Record<string, unknown>, configVal as Record<string, unknown>)
495
+ // Clean up empty parent objects left behind after pruning
496
+ if (Object.keys(configVal as Record<string, unknown>).length === 0) {
497
+ delete configNode[key]
498
+ pruned = true
499
+ }
500
+ continue
501
+ }
502
+
503
+ // Both are non-empty arrays → remove config elements that exist in defaults
504
+ if (Array.isArray(defaultVal) && defaultVal.length > 0 && Array.isArray(configVal)) {
505
+ const defaultSet = new Set(defaultVal.map((v) => stableStringify(v)))
506
+ const filtered = configVal.filter((v) => !defaultSet.has(stableStringify(v)))
507
+ if (filtered.length !== configVal.length) {
508
+ if (filtered.length === 0) {
509
+ delete configNode[key]
510
+ } else {
511
+ configNode[key] = filtered
512
+ }
513
+ pruned = true
514
+ }
515
+ continue
516
+ }
517
+
518
+ // Leaf comparison: use stableStringify for key-order-insensitive deep equality
519
+ if (stableStringify(configVal) === stableStringify(defaultVal)) {
520
+ delete configNode[key]
521
+ pruned = true
522
+ }
523
+ }
524
+ }
525
+
526
+ walk(defaults, config)
527
+ return pruned
528
+ }
529
+
530
+ /**
531
+ * Load and parse clawly-config-defaults.json5 from the plugin install directory.
532
+ * Uses the bundled `json5` package (declared in package.json).
533
+ */
534
+ function loadIncludedDefaults(stateDir: string): Record<string, unknown> | null {
535
+ const defaultsPath = path.join(stateDir, INCLUDE_PATH)
536
+ try {
537
+ const raw = fs.readFileSync(defaultsPath, 'utf-8')
538
+ return JSON5.parse(raw) as Record<string, unknown>
539
+ } catch {
540
+ return null
541
+ }
542
+ }
543
+
749
544
  function reconcileRuntimeConfig(
750
545
  api: PluginApi,
751
- config: Record<string, unknown>,
546
+ config: OpenClawConfig,
752
547
  pc: ConfigPluginConfig,
548
+ stateDir: string,
753
549
  ): boolean {
754
550
  // Ensure gateway credentials are available to all patchers.
755
551
  // After a force-update, runtime pluginConfig may be empty and on-disk
@@ -764,17 +560,20 @@ function reconcileRuntimeConfig(
764
560
  }
765
561
 
766
562
  let dirty = false
767
- dirty = patchEnvVars(config as import('./types/openclaw').OpenClawConfig, pc, api) || dirty
563
+ dirty = patchEnvVars(config, pc, api) || dirty
768
564
  dirty = patchAgent(config, pc) || dirty
769
- dirty = patchContextPruning(config) || dirty
770
- dirty = patchHeartbeat(config, pc) || dirty
771
565
  dirty = patchGateway(config) || dirty
772
566
  dirty = patchBrowser(config) || dirty
773
567
  dirty = patchSession(config) || dirty
774
- dirty = patchTts(config, pc) || dirty
775
- dirty = patchWebSearch(config, pc) || dirty
776
- dirty = patchMemorySearch(config, pc) || dirty
777
- dirty = patchModelGateway(config as import('./types/openclaw').OpenClawConfig, api) || dirty
568
+ dirty = patchModelGateway(config, api) || dirty
569
+
570
+ const defaults = loadIncludedDefaults(stateDir)
571
+ if (defaults) {
572
+ dirty = pruneIncludedDefaults(config, defaults) || dirty
573
+ } else {
574
+ api.logger.warn('Config setup: failed to load included defaults, skipping dedup.')
575
+ }
576
+
778
577
  return dirty
779
578
  }
780
579
 
@@ -789,6 +588,15 @@ export function setupConfig(api: PluginApi): void {
789
588
  return
790
589
  }
791
590
 
591
+ // OpenClaw exposes three config layers:
592
+ // parsed — raw JSON from openclaw.json (no $include, no env expansion)
593
+ // resolved — parsed + $include deep-merge + ${ENV} interpolation
594
+ // runtime — resolved + SecretRef resolution, cached in-memory snapshot
595
+ //
596
+ // We need parsed: patchers mutate raw fields and write back via
597
+ // writeConfigFile, which diffs against the runtime snapshot and projects
598
+ // changes onto the source file. Using resolved/runtime would inline
599
+ // $include defaults into openclaw.json, defeating the json5 migration.
792
600
  const configPath = path.join(stateDir, 'openclaw.json')
793
601
  const config = readOpenclawConfig(configPath)
794
602
  const pc = toPCMerged(api, config)
@@ -797,12 +605,20 @@ export function setupConfig(api: PluginApi): void {
797
605
  dirty = ensureInclude(config) || dirty
798
606
  hardenIncludePermissions(stateDir, api)
799
607
  dirty = repairLegacyProvisionState(api, config, stateDir) || dirty
800
- dirty = reconcileRuntimeConfig(api, config, pc) || dirty
608
+ dirty = reconcileRuntimeConfig(api, config, pc, stateDir) || dirty
801
609
 
802
610
  if (dirty) {
803
611
  try {
804
612
  writeOpenclawConfig(configPath, config)
805
613
  api.logger.info('Config setup: patched openclaw.json.')
614
+ // Refresh the gateway's in-memory runtime config snapshot.
615
+ // The sync write above updates the file on disk, but the gateway
616
+ // caches config via runtimeConfigSnapshot (set during startup by
617
+ // the secrets system). Without this refresh, loadConfig() keeps
618
+ // returning the stale pre-patch config until the next restart.
619
+ void api.runtime.config.writeConfigFile(config).catch((err) => {
620
+ api.logger.warn(`Config setup: runtime snapshot refresh failed: ${(err as Error).message}`)
621
+ })
806
622
  } catch (err) {
807
623
  api.logger.error(`Config setup failed: ${(err as Error).message}`)
808
624
  }
package/gateway/index.ts CHANGED
@@ -3,7 +3,6 @@ import {registerAgentSend} from './agent'
3
3
  import {registerCalendarNative} from './calendar-native'
4
4
  import {registerAnalytics} from './analytics'
5
5
  import {registerAudit} from './audit'
6
- import {registerNodeDangerousAllowlist} from './node-dangerous-allowlist'
7
6
  import {registerClawhub2gateway} from './clawhub2gateway'
8
7
  import {registerConfigRepair} from './config-repair'
9
8
  import {registerConfigTimezone} from './config-timezone'
@@ -64,6 +63,5 @@ export function registerGateway(api: PluginApi) {
64
63
  registerPairing(api)
65
64
  registerVersion(api)
66
65
  registerAudit(api)
67
- registerNodeDangerousAllowlist(api)
68
66
  registerCalendarNative(api)
69
67
  }
@@ -73,6 +73,11 @@ function createMockApi(): {
73
73
  return {api, logs, handlers}
74
74
  }
75
75
 
76
+ /** Event with a simple assistant message — needed since the skip-on-no-text guard. */
77
+ const eventWithReply = {
78
+ messages: [{role: 'assistant', content: 'Hello from the assistant'}],
79
+ }
80
+
76
81
  // ── Tests ────────────────────────────────────────────────────────
77
82
 
78
83
  beforeEach(() => {
@@ -89,7 +94,7 @@ describe('offline-push', () => {
89
94
  registerOfflinePush(api)
90
95
 
91
96
  const handler = handlers.get('agent_end')!
92
- await handler({}, {sessionKey: 'agent:clawly:main'})
97
+ await handler(eventWithReply, {sessionKey: 'agent:clawly:main'})
93
98
 
94
99
  expect(logs).toContainEqual({
95
100
  level: 'info',
@@ -117,8 +122,8 @@ describe('offline-push', () => {
117
122
 
118
123
  const handler = handlers.get('agent_end')!
119
124
 
120
- await handler({}, {sessionKey: 'agent:clawly:main'})
121
- await handler({}, {sessionKey: 'agent:clawly:main'})
125
+ await handler(eventWithReply, {sessionKey: 'agent:clawly:main'})
126
+ await handler(eventWithReply, {sessionKey: 'agent:clawly:main'})
122
127
 
123
128
  expect(logs.filter((l) => l.msg.includes('notified'))).toHaveLength(2)
124
129
  })
@@ -129,11 +134,11 @@ describe('offline-push', () => {
129
134
 
130
135
  const handler = handlers.get('agent_end')!
131
136
 
132
- await handler({}, {sessionKey: 'agent:clawly:main'})
137
+ await handler(eventWithReply, {sessionKey: 'agent:clawly:main'})
133
138
  expect(lastPushExtras).toEqual({badge: 1})
134
139
  expect(mockBadgeCount).toBe(1)
135
140
 
136
- await handler({}, {sessionKey: 'agent:clawly:main'})
141
+ await handler(eventWithReply, {sessionKey: 'agent:clawly:main'})
137
142
  expect(lastPushExtras).toEqual({badge: 2})
138
143
  expect(mockBadgeCount).toBe(2)
139
144
  })
@@ -143,7 +148,7 @@ describe('offline-push', () => {
143
148
  const {api, handlers} = createMockApi()
144
149
  registerOfflinePush(api)
145
150
 
146
- await handlers.get('agent_end')!({}, {sessionKey: 'agent:clawly:main'})
151
+ await handlers.get('agent_end')!(eventWithReply, {sessionKey: 'agent:clawly:main'})
147
152
 
148
153
  expect(lastPushExtras).toEqual({badge: 1})
149
154
  expect(mockBadgeCount).toBe(0) // incremented then decremented
@@ -153,7 +158,7 @@ describe('offline-push', () => {
153
158
  const {api, logs, handlers} = createMockApi()
154
159
  registerOfflinePush(api)
155
160
 
156
- await handlers.get('agent_end')!({}, {sessionKey: 'agent:clawly:telegram:12345'})
161
+ await handlers.get('agent_end')!(eventWithReply, {sessionKey: 'agent:clawly:telegram:12345'})
157
162
 
158
163
  expect(logs).toContainEqual({
159
164
  level: 'info',
@@ -166,7 +171,9 @@ describe('offline-push', () => {
166
171
  const {api, logs, handlers} = createMockApi()
167
172
  registerOfflinePush(api)
168
173
 
169
- await handlers.get('agent_end')!({}, {sessionKey: 'agent:clawly:cron:weather-check:run:abc123'})
174
+ await handlers.get('agent_end')!(eventWithReply, {
175
+ sessionKey: 'agent:clawly:cron:weather-check:run:abc123',
176
+ })
170
177
 
171
178
  expect(logs).toContainEqual({
172
179
  level: 'info',
@@ -180,7 +187,7 @@ describe('offline-push', () => {
180
187
  registerOfflinePush(api)
181
188
 
182
189
  const handler = handlers.get('agent_end')!
183
- await handler({})
190
+ await handler(eventWithReply)
184
191
 
185
192
  expect(logs).toContainEqual({
186
193
  level: 'info',
@@ -192,7 +199,10 @@ describe('offline-push', () => {
192
199
  const {api, handlers} = createMockApi()
193
200
  registerOfflinePush(api)
194
201
 
195
- await handlers.get('agent_end')!({}, {sessionKey: 'agent:clawly:main', agentId: 'luna'})
202
+ await handlers.get('agent_end')!(eventWithReply, {
203
+ sessionKey: 'agent:clawly:main',
204
+ agentId: 'luna',
205
+ })
196
206
 
197
207
  expect(lastPushOpts?.agentId).toBe('luna')
198
208
  })
@@ -201,8 +211,9 @@ describe('offline-push', () => {
201
211
  const {api, handlers} = createMockApi()
202
212
  registerOfflinePush(api)
203
213
 
204
- await handlers.get('agent_end')!({}, {sessionKey: 'agent:clawly:main'})
214
+ await handlers.get('agent_end')!(eventWithReply, {sessionKey: 'agent:clawly:main'})
205
215
 
216
+ expect(lastPushOpts).not.toBeNull()
206
217
  expect(lastPushOpts?.title).toBeUndefined()
207
218
  })
208
219
 
@@ -223,17 +234,21 @@ describe('offline-push', () => {
223
234
  expect(lastPushOpts?.body).toBe('Hi there! How can I help you today?')
224
235
  })
225
236
 
226
- test('body falls back when no messages', async () => {
227
- const {api, handlers} = createMockApi()
237
+ test('skips push when no messages (no extractable text)', async () => {
238
+ const {api, logs, handlers} = createMockApi()
228
239
  registerOfflinePush(api)
229
240
 
230
241
  await handlers.get('agent_end')!({}, {sessionKey: 'agent:clawly:main'})
231
242
 
232
- expect(lastPushOpts?.body).toBe('Your response is ready')
243
+ expect(lastPushOpts).toBeNull()
244
+ expect(logs).toContainEqual({
245
+ level: 'warn',
246
+ msg: expect.stringContaining('skipped (no extractable assistant text)'),
247
+ })
233
248
  })
234
249
 
235
- test('body falls back when messages has no assistant role', async () => {
236
- const {api, handlers} = createMockApi()
250
+ test('skips push when messages has no assistant role', async () => {
251
+ const {api, logs, handlers} = createMockApi()
237
252
  registerOfflinePush(api)
238
253
 
239
254
  await handlers.get('agent_end')!(
@@ -241,7 +256,11 @@ describe('offline-push', () => {
241
256
  {sessionKey: 'agent:clawly:main'},
242
257
  )
243
258
 
244
- expect(lastPushOpts?.body).toBe('Your response is ready')
259
+ expect(lastPushOpts).toBeNull()
260
+ expect(logs).toContainEqual({
261
+ level: 'warn',
262
+ msg: expect.stringContaining('skipped (no extractable assistant text)'),
263
+ })
245
264
  })
246
265
 
247
266
  test('body strips [[type:value]] placeholders', async () => {
@@ -580,15 +599,15 @@ describe('offline-push with filtered messages', () => {
580
599
  })
581
600
  })
582
601
 
583
- test('sends push when event has no messages (safe default)', async () => {
602
+ test('skips push when event has no messages (no extractable text)', async () => {
584
603
  const {api, logs, handlers} = createMockApi()
585
604
  registerOfflinePush(api)
586
605
 
587
606
  await handlers.get('agent_end')!({}, {sessionKey: 'agent:clawly:main'})
588
607
 
589
608
  expect(logs).toContainEqual({
590
- level: 'info',
591
- msg: expect.stringContaining('notified (session=agent:clawly:main)'),
609
+ level: 'warn',
610
+ msg: expect.stringContaining('skipped (no extractable assistant text)'),
592
611
  })
593
612
  })
594
613
  })
@@ -226,6 +226,7 @@ export function registerOfflinePush(api: PluginApi) {
226
226
 
227
227
  // Extract full assistant text for filtering and preview.
228
228
  const fullText = getLastAssistantText(event.messages)
229
+ const triggerText = getTriggeringUserText(event.messages)
229
230
 
230
231
  // Skip if the message would be filtered by the mobile UI.
231
232
  if (fullText != null) {
@@ -270,6 +271,33 @@ export function registerOfflinePush(api: PluginApi) {
270
271
  return
271
272
  }
272
273
 
274
+ // Defensive: if we can't extract assistant text, sending a generic
275
+ // "Your response is ready" is never useful — the message likely wasn't
276
+ // persisted to the transcript either, so the user opens the app to nothing.
277
+ // Log the messages structure for debugging, then bail.
278
+ if (fullText == null || fullText === '') {
279
+ const msgCount = Array.isArray(event.messages) ? event.messages.length : 'n/a'
280
+ const lastRoles = Array.isArray(event.messages)
281
+ ? event.messages
282
+ .slice(-5)
283
+ .map(
284
+ (m: any) =>
285
+ `${m?.role ?? '?'}(${typeof m?.content === 'string' ? 'str' : Array.isArray(m?.content) ? `parts:${m.content.length}` : typeof m?.content})`,
286
+ )
287
+ .join(', ')
288
+ : 'n/a'
289
+ api.logger.warn(
290
+ `offline-push: skipped (no extractable assistant text) msgCount=${msgCount} lastRoles=[${lastRoles}] triggerText=${triggerText ? `"${triggerText.slice(0, 80)}"` : 'null'}`,
291
+ )
292
+ if (isCron) markCronPushSkipped(sessionKey!, 'no extractable text', false)
293
+ captureEvent('push.skipped', {
294
+ reason: 'no_extractable_text',
295
+ is_cron: isCron,
296
+ ...(sessionKey ? {session_key: sessionKey} : {}),
297
+ })
298
+ return
299
+ }
300
+
273
301
  // Only send push for the main clawly mobile session and cron sessions —
274
302
  // skip channel sessions (telegram, slack, discord, etc.) which have their own delivery.
275
303
  if (sessionKey !== undefined && sessionKey !== 'agent:clawly:main' && !isCron) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2en/clawly-plugins",
3
- "version": "1.30.0-beta.3",
3
+ "version": "1.30.0-beta.4",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "repository": {
@@ -14,6 +14,7 @@
14
14
  "@opentelemetry/resources": "^1.30.0",
15
15
  "@opentelemetry/sdk-logs": "^0.57.0",
16
16
  "file-type": "^21.3.0",
17
+ "json5": "^2.2.3",
17
18
  "mime": "^4.1.0",
18
19
  "posthog-node": "^5.28.0",
19
20
  "zx": "npm:zx@8.8.5-lite"
package/types.ts CHANGED
@@ -22,7 +22,7 @@ export type PluginRuntimeCore = {
22
22
  version: string
23
23
  config: {
24
24
  loadConfig: (...args: unknown[]) => unknown
25
- writeConfigFile: (...args: unknown[]) => unknown
25
+ writeConfigFile: (config: OpenClawConfig) => Promise<unknown>
26
26
  }
27
27
  system: {
28
28
  enqueueSystemEvent: (...args: unknown[]) => void
@@ -1,84 +0,0 @@
1
- /**
2
- * Ensures all dangerous node commands are in gateway.nodes.allowCommands
3
- * so the AI agent can invoke them on connected Clawly nodes.
4
- *
5
- * Dangerous commands are defined by OpenClaw's node-command-policy and
6
- * must be explicitly allowlisted. Safe commands work without allowlisting.
7
- *
8
- * Runs on gateway_start. Writes directly to openclaw.json via
9
- * writeOpenclawConfig (no restart triggered — gateway.* changes are
10
- * classified as "none" by the config watcher).
11
- */
12
- import path from 'node:path'
13
-
14
- import type {PluginApi} from '../types'
15
- import {readOpenclawConfig, writeOpenclawConfig} from '../model-gateway-setup'
16
-
17
- const DANGEROUS_COMMANDS = [
18
- // Browser commands (Mac nodes)
19
- 'browser.proxy',
20
- 'browser.navigate',
21
- 'browser.click',
22
- 'browser.type',
23
- 'browser.screenshot',
24
- 'browser.read',
25
- 'browser.tabs',
26
- 'browser.back',
27
- 'browser.scroll',
28
- 'browser.evaluate',
29
- // Reminders + calendar (iOS nodes)
30
- 'reminders.add',
31
- 'calendar.add',
32
- // Device permissions (iOS nodes) — not in OpenClaw's iOS defaults
33
- 'device.permissions',
34
- 'device.requestPermission',
35
- ]
36
-
37
- export function registerNodeDangerousAllowlist(api: PluginApi) {
38
- api.on('gateway_start', async () => {
39
- let stateDir: string
40
- try {
41
- stateDir = api.runtime.state.resolveStateDir()
42
- } catch {
43
- api.logger.warn('node-dangerous-allowlist: cannot resolve state dir, skipping')
44
- return
45
- }
46
-
47
- const configPath = path.join(stateDir, 'openclaw.json')
48
- let config: Record<string, unknown>
49
- try {
50
- config = readOpenclawConfig(configPath)
51
- } catch {
52
- api.logger.warn('node-dangerous-allowlist: failed to read config, skipping')
53
- return
54
- }
55
-
56
- const gateway = (config.gateway as Record<string, unknown>) ?? {}
57
- const nodes = (gateway.nodes as Record<string, unknown>) ?? {}
58
- const existing: string[] = Array.isArray(nodes.allowCommands)
59
- ? (nodes.allowCommands as string[])
60
- : []
61
-
62
- const existingSet = new Set(existing)
63
- const toAdd = DANGEROUS_COMMANDS.filter((cmd) => !existingSet.has(cmd))
64
-
65
- if (toAdd.length === 0) {
66
- api.logger.info('node-dangerous-allowlist: all commands already allowlisted')
67
- return
68
- }
69
-
70
- nodes.allowCommands = [...existing, ...toAdd]
71
- gateway.nodes = nodes
72
- config.gateway = gateway
73
-
74
- try {
75
- writeOpenclawConfig(configPath, config)
76
- } catch {
77
- api.logger.warn('node-dangerous-allowlist: failed to write config')
78
- return
79
- }
80
- api.logger.info(
81
- `node-dangerous-allowlist: added ${toAdd.length} commands to allowlist: ${toAdd.join(', ')}`,
82
- )
83
- })
84
- }