@2en/clawly-plugins 1.29.0 → 1.30.0-beta.1

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.
@@ -0,0 +1,118 @@
1
+ {
2
+ // Static defaults for clawly-plugins.
3
+ // Deep-merged into openclaw.json via $include — sibling keys in the main
4
+ // config override these values. Arrays are concatenated, so the main config
5
+ // must NOT duplicate arrays defined here.
6
+
7
+ // models.providers — how to connect (endpoint, auth, capability declarations)
8
+ models: {
9
+ providers: {
10
+ "clawly-model-gateway": {
11
+ baseUrl: "${CLAWLY_MODEL_GATEWAY_BASE}/v1",
12
+ apiKey: "${CLAWLY_MODEL_GATEWAY_API_KEY}",
13
+ api: "openai-completions",
14
+ models: [
15
+ {
16
+ id: "moonshotai/kimi-k2.5",
17
+ name: "moonshotai/kimi-k2.5",
18
+ input: ["text", "image"],
19
+ contextWindow: 262144,
20
+ maxTokens: 65535,
21
+ },
22
+ {
23
+ id: "google/gemini-2.5-pro",
24
+ name: "google/gemini-2.5-pro",
25
+ input: ["text", "image"],
26
+ contextWindow: 1048576,
27
+ maxTokens: 65536,
28
+ },
29
+ {
30
+ id: "google/gemini-3-pro-preview",
31
+ name: "google/gemini-3-pro-preview",
32
+ input: ["text", "image"],
33
+ contextWindow: 1048576,
34
+ maxTokens: 65536,
35
+ },
36
+ {
37
+ id: "anthropic/claude-sonnet-4.6",
38
+ name: "anthropic/claude-sonnet-4.6",
39
+ input: ["text", "image"],
40
+ contextWindow: 1000000,
41
+ maxTokens: 128000,
42
+ // api: 'anthropic-messages', // TODO: uncomment once model gateway Anthropic Messages API support is out of testing
43
+ },
44
+ {
45
+ id: "anthropic/claude-opus-4.6",
46
+ name: "anthropic/claude-opus-4.6",
47
+ input: ["text", "image"],
48
+ contextWindow: 1000000,
49
+ maxTokens: 128000,
50
+ // api: 'anthropic-messages', // TODO: uncomment once model gateway Anthropic Messages API support is out of testing
51
+ },
52
+ {
53
+ id: "openai/gpt-5.4",
54
+ name: "openai/gpt-5.4",
55
+ input: ["text", "image"],
56
+ contextWindow: 1050000,
57
+ maxTokens: 128000,
58
+ },
59
+ {
60
+ id: "minimax/minimax-m2.5",
61
+ name: "minimax/minimax-m2.5",
62
+ input: ["text"],
63
+ contextWindow: 196608,
64
+ maxTokens: 196608,
65
+ },
66
+ {
67
+ id: "minimax/minimax-m2.1",
68
+ name: "minimax/minimax-m2.1",
69
+ input: ["text"],
70
+ contextWindow: 196608,
71
+ maxTokens: 196608,
72
+ },
73
+ {
74
+ id: "qwen/qwen3.5-plus-02-15",
75
+ name: "qwen/qwen3.5-plus-02-15",
76
+ input: ["text", "image"],
77
+ contextWindow: 1000000,
78
+ maxTokens: 65536,
79
+ },
80
+ {
81
+ id: "z-ai/glm-5",
82
+ name: "z-ai/glm-5",
83
+ input: ["text"],
84
+ contextWindow: 202752,
85
+ maxTokens: 131072,
86
+ },
87
+ ],
88
+ },
89
+ },
90
+ },
91
+
92
+ // agents.defaults.models — which models the agent can use, display names,
93
+ // and per-model overrides. The whitelist only constrains the UI model picker
94
+ // and fallback lists.
95
+ agents: {
96
+ defaults: {
97
+ model: {
98
+ primary: "clawly-model-gateway/anthropic/claude-sonnet-4.6",
99
+ },
100
+ imageModel: {
101
+ primary: "clawly-model-gateway/qwen/qwen3.5-flash-02-23",
102
+ fallbacks: [],
103
+ },
104
+ models: {
105
+ "clawly-model-gateway/moonshotai/kimi-k2.5": { alias: "Kimi K2.5" },
106
+ "clawly-model-gateway/google/gemini-2.5-pro": { alias: "Gemini 2.5 Pro" },
107
+ "clawly-model-gateway/google/gemini-3-pro-preview": { alias: "Gemini 3 Pro Preview" },
108
+ "clawly-model-gateway/anthropic/claude-sonnet-4.6": { alias: "Claude Sonnet 4.6" },
109
+ "clawly-model-gateway/anthropic/claude-opus-4.6": { alias: "Claude Opus 4.6" },
110
+ "clawly-model-gateway/openai/gpt-5.4": { alias: "GPT-5.4" },
111
+ "clawly-model-gateway/minimax/minimax-m2.5": { alias: "MiniMax M2.5" },
112
+ "clawly-model-gateway/minimax/minimax-m2.1": { alias: "MiniMax M2.1" },
113
+ "clawly-model-gateway/qwen/qwen3.5-plus-02-15": { alias: "Qwen 3.5 Plus" },
114
+ "clawly-model-gateway/z-ai/glm-5": { alias: "GLM-5" },
115
+ },
116
+ },
117
+ },
118
+ }
package/config-setup.ts CHANGED
@@ -22,9 +22,12 @@ import type {PluginApi} from './index'
22
22
  import {autoSanitizeSession} from './gateway/session-sanitize'
23
23
  import {resolveGatewayCredentials} from './resolve-gateway-credentials'
24
24
  import {
25
+ ENV_KEY_API_KEY,
26
+ ENV_KEY_BASE,
25
27
  PROVIDER_NAME,
26
28
  patchModelGateway,
27
29
  readOpenclawConfig,
30
+ stripPathname,
28
31
  writeOpenclawConfig,
29
32
  } from './model-gateway-setup'
30
33
 
@@ -487,7 +490,7 @@ export function patchContextPruning(config: Record<string, unknown>): boolean {
487
490
  return false
488
491
  }
489
492
 
490
- const DEFAULT_HEARTBEAT_MODEL = `${PROVIDER_NAME}/qwen/qwen3.5-flash-02-23`
493
+ const DEFAULT_HEARTBEAT_MODEL = `${PROVIDER_NAME}/moonshotai/kimi-k2.5`
491
494
 
492
495
  function resolveDefaultHeartbeatModel(pc: ConfigPluginConfig): string {
493
496
  return toProviderModelId(pc.defaultHeartbeatModel) || DEFAULT_HEARTBEAT_MODEL
@@ -542,6 +545,44 @@ export function patchHeartbeat(config: Record<string, unknown>, pc: ConfigPlugin
542
545
  // return false
543
546
  // }
544
547
 
548
+ export function patchEnvVars(
549
+ config: import('./types/openclaw').OpenClawConfig,
550
+ pc: ConfigPluginConfig,
551
+ api: PluginApi,
552
+ ): boolean {
553
+ // TEMP fallback: recover from legacy provider entry until all sprites are migrated.
554
+ const provider = config.models?.providers?.[PROVIDER_NAME]
555
+
556
+ let dirty = false
557
+ if (!config.env) {
558
+ config.env = {}
559
+ }
560
+ if (!config.env.vars) {
561
+ config.env.vars = {}
562
+ }
563
+ const vars = config.env.vars
564
+
565
+ // CLAWLY_MODEL_GATEWAY_BASE
566
+ if (!vars[ENV_KEY_BASE]) {
567
+ const urlValue = pc.modelGatewayBaseUrl || provider?.baseUrl || ''
568
+ if (urlValue) {
569
+ vars[ENV_KEY_BASE] = stripPathname(urlValue)
570
+ dirty = true
571
+ }
572
+ }
573
+
574
+ // CLAWLY_MODEL_GATEWAY_API_KEY
575
+ if (!vars[ENV_KEY_API_KEY]) {
576
+ const tokenValue = pc.modelGatewayToken || (provider?.apiKey as string) || ''
577
+ if (tokenValue) {
578
+ vars[ENV_KEY_API_KEY] = tokenValue
579
+ dirty = true
580
+ }
581
+ }
582
+
583
+ return dirty
584
+ }
585
+
545
586
  export function patchSession(config: Record<string, unknown>): boolean {
546
587
  let dirty = false
547
588
 
@@ -585,6 +626,50 @@ export function patchSession(config: Record<string, unknown>): boolean {
585
626
  return dirty
586
627
  }
587
628
 
629
+ const INCLUDE_PATH = './extensions/clawly-plugins/clawly-config-defaults.json5'
630
+
631
+ /**
632
+ * Ensures our defaults file is listed in the config's `$include` array.
633
+ * Returns true if `$include` was modified.
634
+ */
635
+ export function ensureInclude(config: Record<string, unknown>): boolean {
636
+ const existing = config.$include
637
+ let includes: string[]
638
+
639
+ if (Array.isArray(existing)) {
640
+ includes = existing as string[]
641
+ } else if (typeof existing === 'string') {
642
+ includes = [existing]
643
+ } else {
644
+ includes = []
645
+ }
646
+
647
+ if (includes.includes(INCLUDE_PATH)) return false
648
+
649
+ includes.push(INCLUDE_PATH)
650
+ config.$include = includes.length === 1 ? includes[0] : includes
651
+ return true
652
+ }
653
+
654
+ /**
655
+ * Restrict the $include defaults file to owner-only (0o600), matching
656
+ * openclaw.json. npm/plugin-install creates files with 0o644 by default,
657
+ * which would leave config readable by other local users.
658
+ */
659
+ function hardenIncludePermissions(stateDir: string, api: PluginApi): void {
660
+ const includePath = path.join(stateDir, INCLUDE_PATH)
661
+ try {
662
+ const stat = fs.statSync(includePath)
663
+ const mode = stat.mode & 0o777
664
+ if (mode !== 0o600) {
665
+ fs.chmodSync(includePath, 0o600)
666
+ api.logger.info(`Hardened ${INCLUDE_PATH} permissions to 0600.`)
667
+ }
668
+ } catch {
669
+ // File may not exist yet (first install before plugin copies files)
670
+ }
671
+ }
672
+
588
673
  const PLUGIN_ID = 'clawly-plugins'
589
674
  const NPM_PKG_NAME = '@2en/clawly-plugins'
590
675
 
@@ -663,7 +748,7 @@ function reconcileRuntimeConfig(
663
748
  // Ensure gateway credentials are available to all patchers.
664
749
  // After a force-update, runtime pluginConfig may be empty and on-disk
665
750
  // file plugin config may not have credentials — but they may exist in
666
- // models.providers.clawly-model-gateway (backfilled by prior runs).
751
+ // env.vars (backfilled by prior runs).
667
752
  // resolveGatewayCredentials checks all three sources.
668
753
  if (!pc.modelGatewayBaseUrl || !pc.modelGatewayToken) {
669
754
  const creds = resolveGatewayCredentials(api, config)
@@ -673,6 +758,7 @@ function reconcileRuntimeConfig(
673
758
  }
674
759
 
675
760
  let dirty = false
761
+ dirty = patchEnvVars(config as import('./types/openclaw').OpenClawConfig, pc, api) || dirty
676
762
  dirty = patchAgent(config, pc) || dirty
677
763
  dirty = patchContextPruning(config) || dirty
678
764
  dirty = patchHeartbeat(config, pc) || dirty
@@ -682,7 +768,7 @@ function reconcileRuntimeConfig(
682
768
  dirty = patchTts(config, pc) || dirty
683
769
  dirty = patchWebSearch(config, pc) || dirty
684
770
  dirty = patchMemorySearch(config, pc) || dirty
685
- dirty = patchModelGateway(config, api, pc) || dirty
771
+ dirty = patchModelGateway(config as import('./types/openclaw').OpenClawConfig, api) || dirty
686
772
  return dirty
687
773
  }
688
774
 
@@ -702,6 +788,8 @@ export function setupConfig(api: PluginApi): void {
702
788
  const pc = toPCMerged(api, config)
703
789
 
704
790
  let dirty = false
791
+ dirty = ensureInclude(config) || dirty
792
+ hardenIncludePermissions(stateDir, api)
705
793
  dirty = repairLegacyProvisionState(api, config, stateDir) || dirty
706
794
  dirty = reconcileRuntimeConfig(api, config, pc) || dirty
707
795
 
@@ -10,8 +10,8 @@ import path from 'node:path'
10
10
 
11
11
  import type {PluginApi} from '../types'
12
12
  import {
13
- EXTRA_GATEWAY_MODELS,
14
- PROVIDER_NAME,
13
+ ENV_KEY_API_KEY,
14
+ ENV_KEY_BASE,
15
15
  readOpenclawConfig,
16
16
  writeOpenclawConfig,
17
17
  } from '../model-gateway-setup'
@@ -33,7 +33,7 @@ export function registerConfigRepair(api: PluginApi) {
33
33
  const configPath = path.join(stateDir, 'openclaw.json')
34
34
  const config = readOpenclawConfig(configPath)
35
35
 
36
- const gw = resolveGatewayCredentials(api, config)
36
+ const gw = resolveGatewayCredentials(api, config as Record<string, unknown>)
37
37
  if (!gw) {
38
38
  respond(true, {
39
39
  ...(dryRun ? {ok: false} : {repaired: false}),
@@ -42,102 +42,35 @@ export function registerConfigRepair(api: PluginApi) {
42
42
  return
43
43
  }
44
44
  const {baseUrl, token} = gw
45
- const providers = (config.models as any)?.providers as Record<string, any> | undefined
46
- const provider = providers?.[PROVIDER_NAME] as Record<string, unknown> | undefined
45
+ const vars = config.env?.vars
47
46
 
48
- // Check current provider state
49
- const currentBaseUrl = typeof provider?.baseUrl === 'string' ? provider.baseUrl : ''
50
- const currentApiKey = typeof provider?.apiKey === 'string' ? provider.apiKey : ''
51
-
52
- const needsRepair = !provider || !currentBaseUrl || !currentApiKey
53
-
54
- if (!needsRepair) {
47
+ if (vars?.[ENV_KEY_BASE] && vars?.[ENV_KEY_API_KEY]) {
55
48
  respond(true, {
56
49
  ...(dryRun ? {ok: true} : {repaired: false}),
57
- detail: 'Provider credentials intact',
50
+ detail: 'env.vars credentials intact',
58
51
  })
59
52
  return
60
53
  }
61
54
 
62
- // Dry-run: report status without mutating
63
55
  if (dryRun) {
64
- const missing: string[] = []
65
- if (!provider) missing.push('provider entry')
66
- else {
67
- if (!currentBaseUrl) missing.push('baseUrl')
68
- if (!currentApiKey) missing.push('apiKey')
69
- }
70
- respond(true, {ok: false, detail: `Missing: ${missing.join(', ')}`})
56
+ respond(true, {ok: false, detail: 'Missing: env.vars credentials'})
71
57
  return
72
58
  }
73
59
 
74
- // Repair: derive models from agents.defaults (same logic as model-gateway-setup).
75
- // Image fallback is handled server-side by the model-gateway proxy, so only
76
- // the default chat model is registered (with image support).
77
- const defaultModelFull: string = (config.agents as any)?.defaults?.model?.primary ?? ''
78
-
79
- const prefix = `${PROVIDER_NAME}/`
80
- const defaultModel = defaultModelFull.startsWith(prefix)
81
- ? defaultModelFull.slice(prefix.length)
82
- : defaultModelFull
83
-
84
- const defaultIds = new Set([defaultModel])
85
- const extraModels = EXTRA_GATEWAY_MODELS.filter((m) => !defaultIds.has(m.id)).map(
86
- ({id, name, input, contextWindow, maxTokens, api}) => ({
87
- id,
88
- name,
89
- input,
90
- contextWindow,
91
- maxTokens,
92
- ...(api ? {api} : {}),
93
- }),
94
- )
95
- const extraMatch = EXTRA_GATEWAY_MODELS.find((m) => m.id === defaultModel)
96
- const models = !defaultModel
97
- ? (provider?.models ?? [])
98
- : [
99
- {
100
- id: defaultModel,
101
- name: defaultModel,
102
- input: extraMatch ? [...extraMatch.input] : (['text', 'image'] as string[]),
103
- ...(extraMatch && {
104
- contextWindow: extraMatch.contextWindow,
105
- maxTokens: extraMatch.maxTokens,
106
- }),
107
- ...(extraMatch?.api ? {api: extraMatch.api} : {}),
108
- },
109
- ...extraModels,
110
- ]
111
-
112
- if (!config.models) config.models = {}
113
- if (!(config.models as any).providers) (config.models as any).providers = {}
114
- ;(config.models as any).providers[PROVIDER_NAME] = {
115
- ...(provider ?? {}),
116
- baseUrl,
117
- apiKey: token,
118
- api: 'openai-completions',
119
- models,
120
- }
121
-
122
- // Write agents.defaults.models alias map (mirrors model-gateway-setup)
123
- if (defaultModel) {
124
- const agents = (config.agents ?? {}) as any
125
- const defaults = agents.defaults ?? {}
126
- const modelsMap: Record<string, {alias: string}> = {
127
- [`${PROVIDER_NAME}/${defaultModel}`]: {alias: defaultModel},
128
- }
129
- for (const m of EXTRA_GATEWAY_MODELS) {
130
- modelsMap[`${PROVIDER_NAME}/${m.id}`] = {alias: m.alias}
131
- }
132
- defaults.models = modelsMap
133
- agents.defaults = defaults
134
- config.agents = agents
60
+ // Write credentials into env.vars for $include resolution.
61
+ if (!config.env) config.env = {}
62
+ if (!config.env.vars) config.env.vars = {}
63
+ try {
64
+ config.env.vars[ENV_KEY_BASE] = new URL(baseUrl).origin
65
+ } catch {
66
+ config.env.vars[ENV_KEY_BASE] = baseUrl
135
67
  }
68
+ config.env.vars[ENV_KEY_API_KEY] = token
136
69
 
137
70
  try {
138
71
  writeOpenclawConfig(configPath, config)
139
- api.logger.info(`config.repair: restored provider credentials (baseUrl + apiKey)`)
140
- respond(true, {repaired: true, detail: 'Provider credentials restored'})
72
+ api.logger.info(`config.repair: restored env.vars credentials`)
73
+ respond(true, {repaired: true, detail: 'env.vars credentials restored'})
141
74
  } catch (err) {
142
75
  const msg = err instanceof Error ? err.message : String(err)
143
76
  api.logger.error(`config.repair: write failed — ${msg}`)
@@ -279,17 +279,35 @@ describe('cron-delivery', () => {
279
279
  })
280
280
  })
281
281
 
282
- test('does not skip meaningful content ending with HEARTBEAT_OK', async () => {
282
+ test('does not skip meaningful content ending with HEARTBEAT_OK when long enough', async () => {
283
283
  const {api, handlers} = createMockApi()
284
284
  registerCronDelivery(api)
285
285
 
286
+ // Content after stripping HEARTBEAT_OK must exceed 300 chars to pass through
287
+ const longContent = 'A'.repeat(301) + ' HEARTBEAT_OK'
286
288
  await handlers.get('agent_end')!(
287
- {messages: makeMessages('Weather is good today. HEARTBEAT_OK')},
289
+ {messages: makeMessages(longContent)},
288
290
  {sessionKey: 'agent:clawly:cron:weather-check', agentId: 'clawly'},
289
291
  )
290
292
 
291
293
  expect(injectCalls).toHaveLength(1)
292
294
  })
295
+
296
+ test('skips short content ending with HEARTBEAT_OK', async () => {
297
+ const {api, logs, handlers} = createMockApi()
298
+ registerCronDelivery(api)
299
+
300
+ await handlers.get('agent_end')!(
301
+ {messages: makeMessages('Weather is good today. HEARTBEAT_OK')},
302
+ {sessionKey: 'agent:clawly:cron:weather-check', agentId: 'clawly'},
303
+ )
304
+
305
+ expect(injectCalls).toHaveLength(0)
306
+ expect(logs).toContainEqual({
307
+ level: 'info',
308
+ msg: expect.stringContaining('skipped (filtered: heartbeat ack)'),
309
+ })
310
+ })
293
311
  })
294
312
 
295
313
  describe('agentId validation', () => {
@@ -327,7 +345,9 @@ describe('cron-delivery', () => {
327
345
  ])
328
346
  expect(logs).toContainEqual({
329
347
  level: 'info',
330
- msg: 'cron-delivery: injected into agent:clawly:main (messageId=msg-001)',
348
+ msg: expect.stringContaining(
349
+ 'cron-delivery: injected into agent:clawly:main (messageId=msg-001)',
350
+ ),
331
351
  })
332
352
  })
333
353
 
@@ -360,7 +380,7 @@ describe('cron-delivery', () => {
360
380
  expect(injectCalls).toHaveLength(0)
361
381
  expect(logs).toContainEqual({
362
382
  level: 'error',
363
- msg: 'cron-delivery: sessions.resolve failed: JSON parse error',
383
+ msg: expect.stringContaining('sessions.resolve failed: JSON parse error'),
364
384
  })
365
385
  })
366
386
 
@@ -378,7 +398,7 @@ describe('cron-delivery', () => {
378
398
  expect(injectCalls).toHaveLength(1)
379
399
  expect(logs).toContainEqual({
380
400
  level: 'error',
381
- msg: 'cron-delivery: chat.inject failed: timeout',
401
+ msg: expect.stringContaining('chat.inject failed: timeout'),
382
402
  })
383
403
  })
384
404
  })
@@ -59,9 +59,13 @@ export function registerCronDelivery(api: PluginApi) {
59
59
  `cron-delivery[debug]: agent_end fired sessionKey=${sessionKey} agentId=${agentId ?? 'undefined'} trigger=${String(ctx?.trigger ?? 'undefined')}`,
60
60
  )
61
61
 
62
+ const t0 = Date.now()
62
63
  try {
63
64
  // Extract raw assistant text (preserving formatting)
64
65
  const text = getRawLastAssistantText(event.messages)
66
+ api.logger.info(
67
+ `cron-delivery[debug]: extracted text len=${text?.length ?? 'null'} elapsed=${Date.now() - t0}ms`,
68
+ )
65
69
  if (text == null) {
66
70
  api.logger.info('cron-delivery: skipped (no assistant message)')
67
71
  if (sessionKey) markCronDeliverySkipped(sessionKey, 'no assistant message')
@@ -97,9 +101,18 @@ export function registerCronDelivery(api: PluginApi) {
97
101
  return
98
102
  }
99
103
 
104
+ api.logger.info(
105
+ `cron-delivery[debug]: resolving session for agentId=${agentId} elapsed=${Date.now() - t0}ms`,
106
+ )
100
107
  const mainSessionKey = await resolveSessionKey(agentId, api)
108
+ api.logger.info(
109
+ `cron-delivery[debug]: resolved mainSessionKey=${mainSessionKey} elapsed=${Date.now() - t0}ms`,
110
+ )
101
111
 
102
112
  // Inject the cron result into the main session
113
+ api.logger.info(
114
+ `cron-delivery[debug]: injecting message len=${text.length} into ${mainSessionKey} elapsed=${Date.now() - t0}ms`,
115
+ )
103
116
  const result = await injectAssistantMessage(
104
117
  {
105
118
  sessionKey: mainSessionKey,
@@ -110,12 +123,12 @@ export function registerCronDelivery(api: PluginApi) {
110
123
 
111
124
  if (sessionKey) markCronDelivered(sessionKey)
112
125
  api.logger.info(
113
- `cron-delivery: injected into ${mainSessionKey} (messageId=${result.messageId})`,
126
+ `cron-delivery: injected into ${mainSessionKey} (messageId=${result.messageId}) elapsed=${Date.now() - t0}ms`,
114
127
  )
115
128
  } catch (err) {
116
129
  const msg = err instanceof Error ? err.message : String(err)
117
130
  if (sessionKey) markCronDeliverySkipped(sessionKey, msg)
118
- api.logger.error(`cron-delivery: ${msg}`)
131
+ api.logger.error(`cron-delivery: error after ${Date.now() - t0}ms — ${msg}`)
119
132
  }
120
133
  })
121
134
 
@@ -391,21 +391,40 @@ describe('shouldSkipPushForMessage', () => {
391
391
  expect(shouldSkipPushForMessage('HEARTBEAT_OK\n')).toBe('heartbeat ack')
392
392
  })
393
393
 
394
- test('does NOT skip meaningful content ending with HEARTBEAT_OK', () => {
395
- expect(shouldSkipPushForMessage('All good. HEARTBEAT_OK')).toBeNull()
396
- expect(shouldSkipPushForMessage('Nothing to report. HEARTBEAT_OK')).toBeNull()
394
+ test('skips short content ending with HEARTBEAT_OK (under ackMaxChars threshold)', () => {
395
+ // "All good." is 9 chars — well under 300 threshold, matches OpenClaw heartbeat runner behavior
396
+ expect(shouldSkipPushForMessage('All good. HEARTBEAT_OK')).toBe('heartbeat ack')
397
+ expect(shouldSkipPushForMessage('Nothing to report. HEARTBEAT_OK。')).toBe('heartbeat ack')
397
398
  })
398
399
 
399
- test('does NOT skip verbose heartbeat response with content before HEARTBEAT_OK', () => {
400
+ test('skips verbose heartbeat response under ackMaxChars threshold', () => {
401
+ // 89 chars remaining — under 300, matches OpenClaw behavior
400
402
  const verbose =
401
403
  'The user said hello recently. Looking at HEARTBEAT.md checklist: nothing needs attention. HEARTBEAT_OK'
402
- expect(shouldSkipPushForMessage(verbose)).toBeNull()
404
+ expect(shouldSkipPushForMessage(verbose)).toBe('heartbeat ack')
403
405
  })
404
406
 
405
- test('does not skip message mentioning HEARTBEAT_OK mid-text', () => {
407
+ test('skips HEARTBEAT_OK at start with short status note', () => {
408
+ expect(shouldSkipPushForMessage('HEARTBEAT_OK — all systems nominal.')).toBe('heartbeat ack')
409
+ expect(shouldSkipPushForMessage('HEARTBEAT_OK. Nothing to report.')).toBe('heartbeat ack')
410
+ })
411
+
412
+ test('does NOT skip HEARTBEAT_OK with >300 chars of real content', () => {
413
+ const longContent = 'A'.repeat(301)
414
+ expect(shouldSkipPushForMessage(`HEARTBEAT_OK ${longContent}`)).toBeNull()
415
+ expect(shouldSkipPushForMessage(`${longContent} HEARTBEAT_OK`)).toBeNull()
416
+ })
417
+
418
+ test('skips message mentioning HEARTBEAT_OK at start with short remaining text', () => {
419
+ // "is a status code I output after each check." is 45 chars — under 300, matches OpenClaw's stripTokenAtEdges
406
420
  expect(
407
421
  shouldSkipPushForMessage('HEARTBEAT_OK is a status code I output after each check.'),
408
- ).toBeNull()
422
+ ).toBe('heartbeat ack')
423
+ })
424
+
425
+ test('does NOT skip HEARTBEAT_OK appearing only in middle of text', () => {
426
+ expect(shouldSkipPushForMessage('You asked about HEARTBEAT_OK earlier.')).toBeNull()
427
+ expect(shouldSkipPushForMessage('The token HEARTBEAT_OK is used for health checks.')).toBeNull()
409
428
  })
410
429
 
411
430
  test('skips system prompt leak', () => {
@@ -497,7 +516,7 @@ describe('offline-push with filtered messages', () => {
497
516
  })
498
517
  })
499
518
 
500
- test('sends push for meaningful heartbeat response and strips HEARTBEAT_OK from body', async () => {
519
+ test('skips push for short heartbeat response with HEARTBEAT_OK (under ackMaxChars)', async () => {
501
520
  const {api, logs, handlers} = createMockApi()
502
521
  registerOfflinePush(api)
503
522
 
@@ -513,11 +532,37 @@ describe('offline-push with filtered messages', () => {
513
532
  {sessionKey: 'agent:clawly:main'},
514
533
  )
515
534
 
535
+ expect(logs).toContainEqual({
536
+ level: 'info',
537
+ msg: expect.stringContaining('skipped (filtered: heartbeat ack)'),
538
+ })
539
+ expect(logs.filter((l) => l.msg.includes('notified'))).toHaveLength(0)
540
+ })
541
+
542
+ test('sends push for long heartbeat response over ackMaxChars and strips HEARTBEAT_OK from body', async () => {
543
+ const {api, logs, handlers} = createMockApi()
544
+ registerOfflinePush(api)
545
+
546
+ const longContent = 'A'.repeat(301)
547
+ await handlers.get('agent_end')!(
548
+ {
549
+ messages: [
550
+ {
551
+ role: 'assistant',
552
+ content: `${longContent}\n\nHEARTBEAT_OK`,
553
+ },
554
+ ],
555
+ },
556
+ {sessionKey: 'agent:clawly:main'},
557
+ )
558
+
516
559
  expect(logs).toContainEqual({
517
560
  level: 'info',
518
561
  msg: expect.stringContaining('notified (session=agent:clawly:main)'),
519
562
  })
520
- expect(lastPushOpts?.body).toBe('Hey! Just checking in saw some interesting news today.')
563
+ // HEARTBEAT_OK should be stripped from body, content truncated to 140
564
+ expect(lastPushOpts?.body?.length).toBe(141)
565
+ expect(lastPushOpts?.body?.endsWith('…')).toBe(true)
521
566
  })
522
567
 
523
568
  test('sends push for normal message text', async () => {
@@ -715,7 +760,7 @@ describe('isInternalRuntimeContextTriggered', () => {
715
760
  // ── Heartbeat-triggered integration tests ────────────────────────
716
761
 
717
762
  describe('offline-push with heartbeat-triggered turns', () => {
718
- test('skips push for verbose heartbeat response', async () => {
763
+ test('skips push for verbose heartbeat response (caught by content filter before trigger check)', async () => {
719
764
  const {api, logs, handlers} = createMockApi()
720
765
  registerOfflinePush(api)
721
766
 
@@ -732,9 +777,10 @@ describe('offline-push with heartbeat-triggered turns', () => {
732
777
  {sessionKey: 'agent:clawly:main'},
733
778
  )
734
779
 
780
+ // Now caught by shouldSkipPushForMessage (content under 300 chars) before trigger check
735
781
  expect(logs).toContainEqual({
736
782
  level: 'info',
737
- msg: 'offline-push: skipped (heartbeat-triggered turn)',
783
+ msg: expect.stringContaining('skipped (filtered: heartbeat ack)'),
738
784
  })
739
785
  expect(logs.filter((l) => l.msg.includes('notified'))).toHaveLength(0)
740
786
  })
@@ -157,10 +157,17 @@ export function shouldSkipPushForMessage(text: string): string | null {
157
157
  // — the scenario is extremely unlikely and the cost is a missed push, not hidden UI.
158
158
  if (/NO_REPLY[\p{P}\s]*$/u.test(text)) return 'silent reply'
159
159
 
160
- // Heartbeat acknowledgment (HEARTBEAT_OK as ending sentinel) only skip if no substantial content before it
161
- if (/HEARTBEAT_OK[\p{P}\s]*$/u.test(text)) {
162
- const stripped = text.replace(/HEARTBEAT_OK[\p{P}\s]*$/u, '').trim()
163
- if (stripped.length === 0) return 'heartbeat ack'
160
+ // Heartbeat acknowledgment — strip HEARTBEAT_OK from both edges (mirrors
161
+ // OpenClaw's stripTokenAtEdges). Skip if remaining text ≤ ackMaxChars (300).
162
+ const HEARTBEAT_ACK_MAX_CHARS = 300
163
+ const hasAtEnd = /HEARTBEAT_OK[\p{P}\s]*$/u.test(text)
164
+ const hasAtStart = /^[\p{P}\s]*HEARTBEAT_OK/u.test(text)
165
+ if (hasAtEnd || hasAtStart) {
166
+ let stripped = text
167
+ if (hasAtEnd) stripped = stripped.replace(/HEARTBEAT_OK[\p{P}\s]*$/u, '')
168
+ if (hasAtStart) stripped = stripped.replace(/^[\p{P}\s]*HEARTBEAT_OK[\p{P}\s]*/u, '')
169
+ stripped = stripped.trim()
170
+ if (stripped.length <= HEARTBEAT_ACK_MAX_CHARS) return 'heartbeat ack'
164
171
  }
165
172
 
166
173
  // Agent echoed system prompt metadata — mobile hides as "systemPromptLeak"
@@ -270,7 +277,17 @@ export function registerOfflinePush(api: PluginApi) {
270
277
  return
271
278
  }
272
279
 
273
- const noHeartbeat = fullText?.replace(/HEARTBEAT_OK[\p{P}\s]*$/u, '').trim() ?? null
280
+ const noHeartbeat =
281
+ (() => {
282
+ if (!fullText) return null
283
+ const atEnd = /HEARTBEAT_OK[\p{P}\s]*$/u.test(fullText)
284
+ const atStart = /^[\p{P}\s]*HEARTBEAT_OK/u.test(fullText)
285
+ if (!atEnd && !atStart) return fullText.trim()
286
+ let s = fullText
287
+ if (atEnd) s = s.replace(/HEARTBEAT_OK[\p{P}\s]*$/u, '')
288
+ if (atStart) s = s.replace(/^[\p{P}\s]*HEARTBEAT_OK[\p{P}\s]*/u, '')
289
+ return s.trim()
290
+ })() ?? null
274
291
  const cleaned = noHeartbeat ? stripPlaceholders(noHeartbeat) : null
275
292
  const preview = cleaned && cleaned.length > 140 ? `${cleaned.slice(0, 140)}…` : cleaned
276
293
  const body = preview || 'Your response is ready'
@@ -6,8 +6,8 @@ describe('isOnlineEntry', () => {
6
6
  expect(isOnlineEntry({host: 'openclaw-ios', reason: 'foreground'})).toBe(true)
7
7
  })
8
8
 
9
- test('returns true for reason "connect"', () => {
10
- expect(isOnlineEntry({host: 'openclaw-ios', reason: 'connect'})).toBe(true)
9
+ test('returns false for reason "connect" (WebSocket alive but not foreground)', () => {
10
+ expect(isOnlineEntry({host: 'openclaw-ios', reason: 'connect'})).toBe(false)
11
11
  })
12
12
 
13
13
  test('returns false for reason "background"', () => {
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Online presence check — queries `system-presence` via the gateway CLI
3
- * and checks if the mobile client (`openclaw-ios`) is connected.
3
+ * and checks if ANY device is in the foreground.
4
4
  *
5
- * Method: clawly.isOnline({ host? }) → { isOnline: boolean }
5
+ * Method: clawly.isOnline() → { isOnline: boolean }
6
6
  */
7
7
 
8
8
  import {$} from 'zx'
@@ -12,43 +12,38 @@ import {stripCliLogs} from '../lib/stripCliLogs'
12
12
 
13
13
  $.verbose = false
14
14
 
15
- const DEFAULT_HOST = 'openclaw-ios'
16
-
17
15
  interface PresenceEntry {
18
16
  host?: string
19
17
  reason?: string
20
18
  }
21
19
 
22
- /** Returns true if the presence entry indicates the client is connected.
20
+ /** Returns true if the presence entry indicates the user is actively viewing the app.
23
21
  *
24
- * ENG-1752: 之前只信任 'foreground' 信号,忽略了 'connect',导致 dev/nightly
25
- * 环境在 app active 时仍返回 isOnline=false,触发了不必要的 push 通知。
26
- * 恢复 'connect' 判断以修复在线检测。 */
22
+ * Only `foreground` counts — `connect` merely means the WebSocket is alive,
23
+ * which is true for backgrounded apps and should not suppress push notifications. */
27
24
  export function isOnlineEntry(entry: PresenceEntry | undefined): boolean {
28
25
  if (!entry) return false
29
- return entry.reason === 'foreground' || entry.reason === 'connect'
26
+ return entry.reason === 'foreground'
30
27
  }
31
28
 
32
29
  /**
33
30
  * Shells out to `openclaw gateway call system-presence` and checks
34
- * whether the given host has a non-"disconnect" entry.
31
+ * whether ANY device has a foreground presence entry.
35
32
  */
36
- export async function isClientOnline(host = DEFAULT_HOST): Promise<boolean> {
33
+ export async function isClientOnline(): Promise<boolean> {
37
34
  try {
38
35
  const result = await $`openclaw gateway call system-presence --json`
39
36
  const jsonStr = stripCliLogs(result.stdout)
40
37
  const entries: PresenceEntry[] = JSON.parse(jsonStr)
41
- const entry = entries.find((e) => e.host === host)
42
- return isOnlineEntry(entry)
38
+ return entries.some(isOnlineEntry)
43
39
  } catch {
44
40
  return false
45
41
  }
46
42
  }
47
43
 
48
44
  export function registerPresence(api: PluginApi) {
49
- api.registerGatewayMethod('clawly.isOnline', async ({params, respond}) => {
50
- const host = typeof params.host === 'string' ? params.host : DEFAULT_HOST
51
- const isOnline = await isClientOnline(host)
45
+ api.registerGatewayMethod('clawly.isOnline', async ({respond}) => {
46
+ const isOnline = await isClientOnline()
52
47
  respond(true, {isOnline})
53
48
  })
54
49
 
@@ -1,122 +1,23 @@
1
1
  /**
2
- * Reconciles the `clawly-model-gateway` provider in openclaw.json from
3
- * pluginConfig inputs and the current runtime-facing agent defaults.
2
+ * Reconciles the `clawly-model-gateway` provider in openclaw.json.
4
3
  *
5
- * This is a runtime reconcile path, not a provision/bootstrap contract.
6
- * The first-boot correctness of gateway startup should not rely on this file
7
- * write winning a race against OpenClaw's internal startup snapshot timing.
4
+ * Static provider config (baseUrl, apiKey, api, models, aliases) is declarative
5
+ * via $include (clawly-config-defaults.json5) with env var references. This
6
+ * file only handles:
7
+ * - Migration: delete inline models, aliases from provider (now from $include)
8
8
  *
9
- * Important boundary: this file intentionally keeps only the instance-visible
10
- * public model view. Provider routing and upstream model mapping remain
11
- * repository-internal concerns owned by the backend.
9
+ * env.vars backfill is handled by patchEnvVars in config-setup.ts.
12
10
  */
13
11
 
14
12
  import fs from 'node:fs'
15
- import path from 'node:path'
16
13
 
17
14
  import type {PluginApi} from './index'
15
+ import type {OpenClawConfig} from './types/openclaw'
18
16
 
19
17
  export const PROVIDER_NAME = 'clawly-model-gateway'
20
- type ModelGatewayConfigInputs = {
21
- modelGatewayBaseUrl?: string
22
- modelGatewayToken?: string
23
- }
24
-
25
- export const PUBLIC_GATEWAY_MODELS = [
26
- {
27
- canonicalId: 'moonshotai/kimi-k2.5',
28
- alias: 'Kimi K2.5',
29
- input: ['text', 'image'],
30
- contextWindow: 262_144,
31
- maxTokens: 65_535,
32
- },
33
- {
34
- canonicalId: 'google/gemini-2.5-pro',
35
- alias: 'Gemini 2.5 Pro',
36
- input: ['text', 'image'],
37
- contextWindow: 1_048_576,
38
- maxTokens: 65_536,
39
- },
40
- {
41
- canonicalId: 'google/gemini-3-pro-preview',
42
- alias: 'Gemini 3 Pro Preview',
43
- input: ['text', 'image'],
44
- contextWindow: 1_048_576,
45
- maxTokens: 65_536,
46
- },
47
- {
48
- canonicalId: 'anthropic/claude-sonnet-4.6',
49
- alias: 'Claude Sonnet 4.6',
50
- input: ['text', 'image'],
51
- contextWindow: 1_000_000,
52
- maxTokens: 128_000,
53
- // api: 'anthropic-messages', // TODO: uncomment once model gateway Anthropic Messages API support is out of testing
54
- },
55
- {
56
- canonicalId: 'anthropic/claude-opus-4.6',
57
- alias: 'Claude Opus 4.6',
58
- input: ['text', 'image'],
59
- contextWindow: 1_000_000,
60
- maxTokens: 128_000,
61
- // api: 'anthropic-messages',
62
- },
63
- {
64
- canonicalId: 'openai/gpt-5.4',
65
- alias: 'GPT-5.4',
66
- input: ['text', 'image'],
67
- contextWindow: 1_050_000,
68
- maxTokens: 128_000,
69
- },
70
- {
71
- canonicalId: 'minimax/minimax-m2.5',
72
- alias: 'MiniMax M2.5',
73
- input: ['text'],
74
- contextWindow: 196_608,
75
- maxTokens: 196_608,
76
- },
77
- {
78
- canonicalId: 'minimax/minimax-m2.1',
79
- alias: 'MiniMax M2.1',
80
- input: ['text'],
81
- contextWindow: 196_608,
82
- maxTokens: 196_608,
83
- },
84
- {
85
- canonicalId: 'qwen/qwen3.5-plus-02-15',
86
- alias: 'Qwen 3.5 Plus',
87
- input: ['text', 'image'],
88
- contextWindow: 1_000_000,
89
- maxTokens: 65_536,
90
- },
91
- {
92
- canonicalId: 'z-ai/glm-5',
93
- alias: 'GLM-5',
94
- input: ['text'],
95
- contextWindow: 202_752,
96
- maxTokens: 131_072,
97
- },
98
- ] as const
99
-
100
- /** Additional models available through the model gateway (beyond env-configured defaults). */
101
- export const EXTRA_GATEWAY_MODELS: Array<{
102
- id: string
103
- name: string
104
- alias: string
105
- input: string[]
106
- contextWindow: number
107
- maxTokens: number
108
- api?: string
109
- }> = PUBLIC_GATEWAY_MODELS.map((model) => ({
110
- id: model.canonicalId,
111
- name: model.canonicalId,
112
- alias: model.alias,
113
- input: [...model.input],
114
- contextWindow: model.contextWindow,
115
- maxTokens: model.maxTokens,
116
- ...('api' in model && model.api ? {api: model.api} : {}),
117
- }))
118
-
119
- export function readOpenclawConfig(configPath: string): Record<string, unknown> {
18
+ export const ENV_KEY_BASE = 'CLAWLY_MODEL_GATEWAY_BASE'
19
+ export const ENV_KEY_API_KEY = 'CLAWLY_MODEL_GATEWAY_API_KEY'
20
+ export function readOpenclawConfig(configPath: string): OpenClawConfig {
120
21
  try {
121
22
  return JSON.parse(fs.readFileSync(configPath, 'utf-8'))
122
23
  } catch {
@@ -124,211 +25,45 @@ export function readOpenclawConfig(configPath: string): Record<string, unknown>
124
25
  }
125
26
  }
126
27
 
127
- export function writeOpenclawConfig(configPath: string, config: Record<string, unknown>) {
28
+ export function writeOpenclawConfig(configPath: string, config: OpenClawConfig) {
128
29
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n')
129
30
  }
130
31
 
131
- function buildProviderModel(id: string): {
132
- id: string
133
- name: string
134
- input: string[]
135
- contextWindow?: number
136
- maxTokens?: number
137
- api?: string
138
- } {
139
- const extra = EXTRA_GATEWAY_MODELS.find((model) => model.id === id)
140
- return {
141
- id,
142
- name: id,
143
- input: extra ? [...extra.input] : ['text', 'image'],
144
- ...(extra ? {contextWindow: extra.contextWindow, maxTokens: extra.maxTokens} : {}),
145
- ...(extra?.api ? {api: extra.api} : {}),
32
+ /** Strip pathname from a URL, returning just the origin (scheme + host + port). */
33
+ export function stripPathname(url: string): string {
34
+ try {
35
+ const parsed = new URL(url)
36
+ return parsed.origin
37
+ } catch {
38
+ return url
146
39
  }
147
40
  }
148
41
 
149
- export function patchModelGateway(
150
- config: Record<string, unknown>,
151
- api: PluginApi,
152
- inputs?: ModelGatewayConfigInputs,
153
- ): boolean {
154
- const cfg =
155
- inputs ??
156
- (api.pluginConfig as Record<string, unknown> | undefined as
157
- | ModelGatewayConfigInputs
158
- | undefined)
159
-
160
- // If provider already exists, check if extra models or aliases need updating.
161
- // This runs before the credentials check because provisioned sprites have
162
- // credentials in openclaw.json directly, not in pluginConfig.
163
- const existingProvider = (config.models as any)?.providers?.[PROVIDER_NAME]
164
- if (existingProvider) {
165
- let dirty = false
166
- const existingModels: Array<{id: string}> = existingProvider.models ?? []
167
- const existingIds = new Set(existingModels.map((m: {id: string}) => m.id))
168
-
169
- // Append any missing extra models
170
- for (const m of EXTRA_GATEWAY_MODELS) {
171
- if (!existingIds.has(m.id)) {
172
- existingModels.push({
173
- id: m.id,
174
- name: m.name,
175
- input: m.input,
176
- contextWindow: m.contextWindow,
177
- maxTokens: m.maxTokens,
178
- ...(m.api ? {api: m.api} : {}),
179
- } as any)
180
- dirty = true
181
- }
182
- }
183
-
184
- // Backfill contextWindow/maxTokens and api on existing models that lack them
185
- const extraLookup = new Map(EXTRA_GATEWAY_MODELS.map((m) => [m.id, m]))
186
- for (const m of existingModels) {
187
- const extra = extraLookup.get(m.id)
188
- if (!extra) continue
189
- if (
190
- (m as any).contextWindow !== extra.contextWindow ||
191
- (m as any).maxTokens !== extra.maxTokens
192
- ) {
193
- ;(m as any).contextWindow = extra.contextWindow
194
- ;(m as any).maxTokens = extra.maxTokens
195
- dirty = true
196
- }
197
- if (extra.api && (m as any).api !== extra.api) {
198
- ;(m as any).api = extra.api
199
- dirty = true
200
- }
201
- }
202
-
203
- // Ensure aliases exist for ALL models (existing + extras)
204
- const agents = (config.agents ?? {}) as any
205
- const defaults = agents.defaults ?? {}
206
- const existingAliases: Record<string, {alias: string}> = defaults.models ?? {}
207
- for (const m of existingModels) {
208
- const key = `${PROVIDER_NAME}/${m.id}`
209
- if (!existingAliases[key]) {
210
- const extra = EXTRA_GATEWAY_MODELS.find((e) => e.id === m.id)
211
- existingAliases[key] = {alias: extra?.alias ?? m.id}
212
- dirty = true
213
- }
214
- }
42
+ /**
43
+ * @deprecated Migration-only — remove once all sprites have been reprovisioned
44
+ * and no inline models/aliases remain in openclaw.json.
45
+ */
46
+ export function patchModelGateway(config: OpenClawConfig, _api: PluginApi): boolean {
47
+ let dirty = false
48
+ const provider = config.models?.providers?.[PROVIDER_NAME]
49
+
50
+ // Migration: delete inline models array (now from $include).
51
+ if (provider?.models) {
52
+ delete (provider as Record<string, unknown>).models
53
+ dirty = true
54
+ }
215
55
 
216
- // Backfill pluginConfig from existing provider credentials (legacy sprites
217
- // provisioned before plugin config was written during configure phase).
218
- if (
219
- existingProvider.baseUrl &&
220
- existingProvider.apiKey &&
221
- (!cfg?.modelGatewayBaseUrl || !cfg?.modelGatewayToken)
222
- ) {
223
- const entries = (config.plugins as any)?.entries?.['clawly-plugins']
224
- if (entries) {
225
- entries.config = {
226
- ...entries.config,
227
- modelGatewayBaseUrl: existingProvider.baseUrl,
228
- modelGatewayToken: existingProvider.apiKey,
229
- }
56
+ // Migration: delete aliases starting with our provider prefix (now from $include).
57
+ const aliasMap = config.agents?.defaults?.models
58
+ if (aliasMap) {
59
+ const prefix = `${PROVIDER_NAME}/`
60
+ for (const key of Object.keys(aliasMap)) {
61
+ if (key.startsWith(prefix)) {
62
+ delete aliasMap[key]
230
63
  dirty = true
231
- api.logger.info(
232
- 'Model gateway: backfilled pluginConfig from existing provider credentials.',
233
- )
234
64
  }
235
65
  }
236
-
237
- if (dirty) {
238
- existingProvider.models = existingModels
239
- defaults.models = existingAliases
240
- agents.defaults = defaults
241
- config.agents = agents
242
- api.logger.info('Model gateway updated.')
243
- } else {
244
- api.logger.info('Model gateway provider already configured.')
245
- }
246
- return dirty
247
66
  }
248
67
 
249
- // No existing provider — need pluginConfig credentials to create one
250
- const baseUrl =
251
- typeof cfg?.modelGatewayBaseUrl === 'string' ? cfg.modelGatewayBaseUrl.replace(/\/$/, '') : ''
252
- const token = typeof cfg?.modelGatewayToken === 'string' ? cfg.modelGatewayToken : ''
253
-
254
- if (!baseUrl || !token) {
255
- api.logger.info('Model gateway not configured (missing baseUrl or token), skipping.')
256
- return false
257
- }
258
-
259
- // Derive default model ID from agents.defaults.
260
- // Image fallback is handled server-side by the model-gateway proxy, so only
261
- // the default chat model needs to be registered in the provider's model list.
262
- const defaultModelFull: string = (config.agents as any)?.defaults?.model?.primary ?? ''
263
-
264
- const prefix = `${PROVIDER_NAME}/`
265
- const defaultModel = defaultModelFull.startsWith(prefix)
266
- ? defaultModelFull.slice(prefix.length)
267
- : defaultModelFull
268
-
269
- if (!defaultModel) {
270
- api.logger.warn('No default model found in agents.defaults — model gateway setup skipped.')
271
- return false
272
- }
273
-
274
- const defaultModels = [buildProviderModel(defaultModel)]
275
-
276
- const defaultIds = new Set(defaultModels.map((m) => m.id))
277
- const models = [
278
- ...defaultModels,
279
- ...EXTRA_GATEWAY_MODELS.filter((m) => !defaultIds.has(m.id)).map(({id}) =>
280
- buildProviderModel(id),
281
- ),
282
- ]
283
-
284
- if (!config.models) config.models = {}
285
- if (!(config.models as any).providers) (config.models as any).providers = {}
286
- ;(config.models as any).providers[PROVIDER_NAME] = {
287
- baseUrl,
288
- apiKey: token,
289
- api: 'openai-completions',
290
- models,
291
- }
292
-
293
- // Write agents.defaults.models map for UI dropdown aliases
294
- const agents = (config.agents ?? {}) as any
295
- const defaults = agents.defaults ?? {}
296
- const modelsMap: Record<string, {alias: string}> = {
297
- [`${PROVIDER_NAME}/${defaultModel}`]: {alias: defaultModel},
298
- }
299
- for (const m of EXTRA_GATEWAY_MODELS) {
300
- modelsMap[`${PROVIDER_NAME}/${m.id}`] = {alias: m.alias}
301
- }
302
- defaults.models = modelsMap
303
- agents.defaults = defaults
304
- config.agents = agents
305
-
306
- api.logger.info(`Model gateway provider configured: ${baseUrl} with ${models.length} model(s).`)
307
- return true
308
- }
309
-
310
- /**
311
- * Standalone wrapper — reads config, patches, writes. Used by tests.
312
- *
313
- * This helper intentionally does not apply the file-backed pluginConfig fallback
314
- * from setupConfig(); production startup should go through the full runtime
315
- * reconcile path there.
316
- */
317
- export function setupModelGateway(api: PluginApi): void {
318
- const stateDir = api.runtime.state.resolveStateDir()
319
- if (!stateDir) {
320
- api.logger.warn('Cannot resolve state dir — model gateway setup skipped.')
321
- return
322
- }
323
-
324
- const configPath = path.join(stateDir, 'openclaw.json')
325
- const config = readOpenclawConfig(configPath)
326
-
327
- if (!patchModelGateway(config, api)) return
328
-
329
- try {
330
- writeOpenclawConfig(configPath, config)
331
- } catch (err) {
332
- api.logger.error(`Failed to setup model gateway: ${(err as Error).message}`)
333
- }
68
+ return dirty
334
69
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2en/clawly-plugins",
3
- "version": "1.29.0",
3
+ "version": "1.30.0-beta.1",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "repository": {
@@ -36,6 +36,7 @@
36
36
  "resolve-gateway-credentials.ts",
37
37
  "skill-command-restore.ts",
38
38
  "openclaw.plugin.json",
39
+ "clawly-config-defaults.json5",
39
40
  "internal",
40
41
  "skills"
41
42
  ],
@@ -46,5 +47,8 @@
46
47
  "extensions": [
47
48
  "./index.ts"
48
49
  ]
50
+ },
51
+ "devDependencies": {
52
+ "json5": "^2.2.3"
49
53
  }
50
54
  }
@@ -3,18 +3,18 @@
3
3
  *
4
4
  * Fallback order:
5
5
  * 1. Runtime pluginConfig (api.pluginConfig) — always present on fresh provision
6
- * 2. On-disk provider entry (models.providers.clawly-model-gateway) — backfilled by setupConfig
6
+ * 2. On-disk env.vars (CLAWLY_MODEL_GATEWAY_BASE/API_KEY) — written by setupConfig
7
7
  * 3. On-disk file plugin config (plugins.entries.clawly-plugins.config) — written during provision
8
8
  *
9
9
  * After a force-update, runtime pluginConfig may be empty (it's immutable at
10
- * runtime), but setupConfig backfills credentials into the on-disk provider
11
- * and file plugin config entries. This function reads from all three sources
12
- * so callers always get credentials when available.
10
+ * runtime), but setupConfig backfills credentials into env.vars and file
11
+ * plugin config entries. This function reads from all three sources so
12
+ * callers always get credentials when available.
13
13
  */
14
14
 
15
15
  import path from 'node:path'
16
16
 
17
- import {PROVIDER_NAME, readOpenclawConfig} from './model-gateway-setup'
17
+ import {ENV_KEY_API_KEY, ENV_KEY_BASE, readOpenclawConfig} from './model-gateway-setup'
18
18
  import type {PluginApi} from './types'
19
19
 
20
20
  export interface GatewayCredentials {
@@ -43,14 +43,13 @@ export function resolveGatewayCredentials(
43
43
  config = readOpenclawConfig(path.join(stateDir, 'openclaw.json'))
44
44
  }
45
45
 
46
- // 2. On-disk provider entry
47
- const provider = (config.models as any)?.providers?.[PROVIDER_NAME] as
48
- | Record<string, unknown>
49
- | undefined
50
- const provBaseUrl = typeof provider?.baseUrl === 'string' ? provider.baseUrl : ''
51
- const provApiKey = typeof provider?.apiKey === 'string' ? provider.apiKey : ''
52
- if (provBaseUrl && provApiKey) {
53
- return {baseUrl: provBaseUrl.replace(/\/$/, ''), token: provApiKey}
46
+ // 2. On-disk env.vars (credentials stored as env vars for $include resolution)
47
+ const envVars = (config.env as any)?.vars as Record<string, string> | undefined
48
+ const envBase = typeof envVars?.[ENV_KEY_BASE] === 'string' ? envVars[ENV_KEY_BASE] : ''
49
+ const envBaseUrl = envBase ? `${envBase}/v1` : ''
50
+ const envApiKey = typeof envVars?.[ENV_KEY_API_KEY] === 'string' ? envVars[ENV_KEY_API_KEY] : ''
51
+ if (envBaseUrl && envApiKey) {
52
+ return {baseUrl: envBaseUrl.replace(/\/$/, ''), token: envApiKey}
54
53
  }
55
54
 
56
55
  // 3. On-disk file plugin config
@@ -11,22 +11,17 @@ const TOOL_NAME = 'clawly_is_user_online'
11
11
 
12
12
  const parameters: Record<string, unknown> = {
13
13
  type: 'object',
14
- properties: {
15
- host: {
16
- type: 'string',
17
- description: 'Presence host identifier (default: "openclaw-ios")',
18
- },
19
- },
14
+ properties: {},
20
15
  }
21
16
 
22
17
  export function registerIsUserOnlineTool(api: PluginApi) {
23
18
  api.registerTool({
24
19
  name: TOOL_NAME,
25
- description: "Check if the user's mobile device is currently online.",
20
+ description:
21
+ "Check if the user's mobile device is currently online (any device in foreground).",
26
22
  parameters,
27
- async execute(_toolCallId, params) {
28
- const host = typeof params.host === 'string' ? params.host : undefined
29
- const isOnline = await isClientOnline(host)
23
+ async execute() {
24
+ const isOnline = await isClientOnline()
30
25
  return {content: [{type: 'text', text: JSON.stringify({isOnline})}]}
31
26
  },
32
27
  })