@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.
- package/clawly-config-defaults.json5 +118 -0
- package/config-setup.ts +91 -3
- package/gateway/config-repair.ts +17 -84
- package/gateway/cron-delivery.test.ts +25 -5
- package/gateway/cron-delivery.ts +15 -2
- package/gateway/offline-push.test.ts +57 -11
- package/gateway/offline-push.ts +22 -5
- package/gateway/presence.test.ts +2 -2
- package/gateway/presence.ts +11 -16
- package/model-gateway-setup.ts +39 -304
- package/package.json +5 -1
- package/resolve-gateway-credentials.ts +12 -13
- package/tools/clawly-is-user-online.ts +5 -10
|
@@ -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}/
|
|
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
|
-
//
|
|
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
|
|
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
|
|
package/gateway/config-repair.ts
CHANGED
|
@@ -10,8 +10,8 @@ import path from 'node:path'
|
|
|
10
10
|
|
|
11
11
|
import type {PluginApi} from '../types'
|
|
12
12
|
import {
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
46
|
-
const provider = providers?.[PROVIDER_NAME] as Record<string, unknown> | undefined
|
|
45
|
+
const vars = config.env?.vars
|
|
47
46
|
|
|
48
|
-
|
|
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: '
|
|
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
|
-
|
|
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
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
140
|
-
respond(true, {repaired: true, detail: '
|
|
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(
|
|
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:
|
|
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: '
|
|
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: '
|
|
401
|
+
msg: expect.stringContaining('chat.inject failed: timeout'),
|
|
382
402
|
})
|
|
383
403
|
})
|
|
384
404
|
})
|
package/gateway/cron-delivery.ts
CHANGED
|
@@ -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('
|
|
395
|
-
|
|
396
|
-
expect(shouldSkipPushForMessage('
|
|
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('
|
|
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)).
|
|
404
|
+
expect(shouldSkipPushForMessage(verbose)).toBe('heartbeat ack')
|
|
403
405
|
})
|
|
404
406
|
|
|
405
|
-
test('
|
|
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
|
-
).
|
|
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('
|
|
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
|
-
|
|
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: '
|
|
783
|
+
msg: expect.stringContaining('skipped (filtered: heartbeat ack)'),
|
|
738
784
|
})
|
|
739
785
|
expect(logs.filter((l) => l.msg.includes('notified'))).toHaveLength(0)
|
|
740
786
|
})
|
package/gateway/offline-push.ts
CHANGED
|
@@ -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
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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 =
|
|
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'
|
package/gateway/presence.test.ts
CHANGED
|
@@ -6,8 +6,8 @@ describe('isOnlineEntry', () => {
|
|
|
6
6
|
expect(isOnlineEntry({host: 'openclaw-ios', reason: 'foreground'})).toBe(true)
|
|
7
7
|
})
|
|
8
8
|
|
|
9
|
-
test('returns
|
|
10
|
-
expect(isOnlineEntry({host: 'openclaw-ios', reason: 'connect'})).toBe(
|
|
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"', () => {
|
package/gateway/presence.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Online presence check — queries `system-presence` via the gateway CLI
|
|
3
|
-
* and checks if
|
|
3
|
+
* and checks if ANY device is in the foreground.
|
|
4
4
|
*
|
|
5
|
-
* Method: clawly.isOnline(
|
|
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
|
|
20
|
+
/** Returns true if the presence entry indicates the user is actively viewing the app.
|
|
23
21
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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'
|
|
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
|
|
31
|
+
* whether ANY device has a foreground presence entry.
|
|
35
32
|
*/
|
|
36
|
-
export async function isClientOnline(
|
|
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
|
-
|
|
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 ({
|
|
50
|
-
const
|
|
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
|
|
package/model-gateway-setup.ts
CHANGED
|
@@ -1,122 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Reconciles the `clawly-model-gateway` provider in openclaw.json
|
|
3
|
-
* pluginConfig inputs and the current runtime-facing agent defaults.
|
|
2
|
+
* Reconciles the `clawly-model-gateway` provider in openclaw.json.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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:
|
|
28
|
+
export function writeOpenclawConfig(configPath: string, config: OpenClawConfig) {
|
|
128
29
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n')
|
|
129
30
|
}
|
|
130
31
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
): boolean {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
(
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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 {
|
|
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
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
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:
|
|
20
|
+
description:
|
|
21
|
+
"Check if the user's mobile device is currently online (any device in foreground).",
|
|
26
22
|
parameters,
|
|
27
|
-
async execute(
|
|
28
|
-
const
|
|
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
|
})
|