@2en/clawly-plugins 1.22.3 → 1.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config-setup.ts +383 -0
- package/gateway/offline-push.test.ts +8 -33
- package/gateway/offline-push.ts +4 -8
- package/index.ts +2 -4
- package/model-gateway-setup.ts +22 -20
- package/openclaw.plugin.json +11 -2
- package/package.json +2 -2
- package/session-setup.ts +0 -61
package/config-setup.ts
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On plugin init, patches openclaw.json to set all business config that was
|
|
3
|
+
* previously written by provision's buildConfig().
|
|
4
|
+
*
|
|
5
|
+
* Domain helpers read the pluginConfig, apply enforce or set-if-missing
|
|
6
|
+
* semantics, and write once if anything changed. This runs before
|
|
7
|
+
* setupModelGateway so agents.defaults.model is available for the model
|
|
8
|
+
* provider setup.
|
|
9
|
+
*
|
|
10
|
+
* Backward compatibility: old sprites have pluginConfig with only 4 fields
|
|
11
|
+
* (skill/model gateway credentials). Helpers check for the new fields and
|
|
12
|
+
* skip gracefully when absent.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
|
|
17
|
+
import type {PluginApi} from './index'
|
|
18
|
+
import {
|
|
19
|
+
PROVIDER_NAME,
|
|
20
|
+
patchModelGateway,
|
|
21
|
+
readOpenclawConfig,
|
|
22
|
+
resolveStateDir,
|
|
23
|
+
writeOpenclawConfig,
|
|
24
|
+
} from './model-gateway-setup'
|
|
25
|
+
|
|
26
|
+
export interface ConfigPluginConfig {
|
|
27
|
+
agentId?: string
|
|
28
|
+
agentName?: string
|
|
29
|
+
workspaceDir?: string
|
|
30
|
+
defaultModel?: string
|
|
31
|
+
defaultImageModel?: string
|
|
32
|
+
sessionMainKey?: string
|
|
33
|
+
elevenlabsApiKey?: string
|
|
34
|
+
elevenlabsVoiceId?: string
|
|
35
|
+
braveSearchApiKey?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function toPC(api: PluginApi): ConfigPluginConfig {
|
|
39
|
+
return (api.pluginConfig ?? {}) as ConfigPluginConfig
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Domain helpers — each returns true when config was mutated
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
export function patchAgent(config: Record<string, unknown>, pc: ConfigPluginConfig): boolean {
|
|
47
|
+
if (!pc.agentId) return false
|
|
48
|
+
|
|
49
|
+
let dirty = false
|
|
50
|
+
const agents = (config.agents ?? {}) as Record<string, unknown>
|
|
51
|
+
const defaults = (agents.defaults ?? {}) as Record<string, unknown>
|
|
52
|
+
|
|
53
|
+
// agents.list: find-or-create entry by id, then enforce our fields only
|
|
54
|
+
const list = (Array.isArray(agents.list) ? agents.list : []) as Record<string, unknown>[]
|
|
55
|
+
let entry = list.find((e) => e.id === pc.agentId)
|
|
56
|
+
if (!entry) {
|
|
57
|
+
entry = {id: pc.agentId}
|
|
58
|
+
list.push(entry)
|
|
59
|
+
dirty = true
|
|
60
|
+
}
|
|
61
|
+
const agentName = pc.agentName ?? 'Clawly'
|
|
62
|
+
if (entry.name !== agentName) {
|
|
63
|
+
entry.name = agentName
|
|
64
|
+
dirty = true
|
|
65
|
+
}
|
|
66
|
+
if (entry.default !== true) {
|
|
67
|
+
entry.default = true
|
|
68
|
+
dirty = true
|
|
69
|
+
}
|
|
70
|
+
const workspace = pc.workspaceDir ?? ''
|
|
71
|
+
if (entry.workspace !== workspace) {
|
|
72
|
+
entry.workspace = workspace
|
|
73
|
+
dirty = true
|
|
74
|
+
}
|
|
75
|
+
if (agents.list !== list) {
|
|
76
|
+
agents.list = list
|
|
77
|
+
dirty = true
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// model: set-if-missing
|
|
81
|
+
if (pc.defaultModel && !defaults.model) {
|
|
82
|
+
defaults.model = {primary: `${PROVIDER_NAME}/${pc.defaultModel}`}
|
|
83
|
+
dirty = true
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// imageModel: set-if-missing
|
|
87
|
+
if (pc.defaultImageModel && !defaults.imageModel) {
|
|
88
|
+
defaults.imageModel = {
|
|
89
|
+
primary: `${PROVIDER_NAME}/${pc.defaultImageModel}`,
|
|
90
|
+
fallbacks: [],
|
|
91
|
+
}
|
|
92
|
+
dirty = true
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// sandbox.mode: enforce (only set the field we care about)
|
|
96
|
+
const sandbox = (defaults.sandbox ?? {}) as Record<string, unknown>
|
|
97
|
+
if (sandbox.mode !== 'off') {
|
|
98
|
+
sandbox.mode = 'off'
|
|
99
|
+
defaults.sandbox = sandbox
|
|
100
|
+
dirty = true
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// verboseDefault: set-if-missing
|
|
104
|
+
if (defaults.verboseDefault === undefined) {
|
|
105
|
+
defaults.verboseDefault = 'on'
|
|
106
|
+
dirty = true
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (dirty) {
|
|
110
|
+
agents.defaults = defaults
|
|
111
|
+
config.agents = agents
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return dirty
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function patchGateway(config: Record<string, unknown>): boolean {
|
|
118
|
+
let dirty = false
|
|
119
|
+
const gateway = (config.gateway ?? {}) as Record<string, unknown>
|
|
120
|
+
|
|
121
|
+
if (gateway.mode !== 'local') {
|
|
122
|
+
gateway.mode = 'local'
|
|
123
|
+
dirty = true
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// http.endpoints.chatCompletions.enabled: enforce (only set the field we care about)
|
|
127
|
+
const http = (gateway.http ?? {}) as Record<string, unknown>
|
|
128
|
+
const endpoints = (http.endpoints ?? {}) as Record<string, unknown>
|
|
129
|
+
const chatCompletions = (endpoints.chatCompletions ?? {}) as Record<string, unknown>
|
|
130
|
+
if (chatCompletions.enabled !== true) {
|
|
131
|
+
chatCompletions.enabled = true
|
|
132
|
+
endpoints.chatCompletions = chatCompletions
|
|
133
|
+
http.endpoints = endpoints
|
|
134
|
+
gateway.http = http
|
|
135
|
+
dirty = true
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const desiredProxies = ['0.0.0.0/0']
|
|
139
|
+
if (JSON.stringify(gateway.trustedProxies) !== JSON.stringify(desiredProxies)) {
|
|
140
|
+
gateway.trustedProxies = desiredProxies
|
|
141
|
+
dirty = true
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (dirty) config.gateway = gateway
|
|
145
|
+
return dirty
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function patchBrowser(config: Record<string, unknown>): boolean {
|
|
149
|
+
let dirty = false
|
|
150
|
+
|
|
151
|
+
// browser: enforce individual fields only
|
|
152
|
+
const browser = (config.browser ?? {}) as Record<string, unknown>
|
|
153
|
+
if (browser.headless !== true) {
|
|
154
|
+
browser.headless = true
|
|
155
|
+
dirty = true
|
|
156
|
+
}
|
|
157
|
+
if (browser.noSandbox !== true) {
|
|
158
|
+
browser.noSandbox = true
|
|
159
|
+
dirty = true
|
|
160
|
+
}
|
|
161
|
+
if (browser.attachOnly !== false) {
|
|
162
|
+
browser.attachOnly = false
|
|
163
|
+
dirty = true
|
|
164
|
+
}
|
|
165
|
+
if (dirty) config.browser = browser
|
|
166
|
+
|
|
167
|
+
// commands: enforce individual fields only
|
|
168
|
+
const commands = (config.commands ?? {}) as Record<string, unknown>
|
|
169
|
+
if (commands.restart !== true) {
|
|
170
|
+
commands.restart = true
|
|
171
|
+
config.commands = commands
|
|
172
|
+
dirty = true
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return dirty
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function patchSessionMainKey(
|
|
179
|
+
config: Record<string, unknown>,
|
|
180
|
+
pc: ConfigPluginConfig,
|
|
181
|
+
): boolean {
|
|
182
|
+
if (!pc.sessionMainKey) return false
|
|
183
|
+
|
|
184
|
+
const session = (config.session ?? {}) as Record<string, unknown>
|
|
185
|
+
if (session.mainKey === pc.sessionMainKey) return false
|
|
186
|
+
|
|
187
|
+
session.mainKey = pc.sessionMainKey
|
|
188
|
+
config.session = session
|
|
189
|
+
return true
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function patchTts(config: Record<string, unknown>, pc: ConfigPluginConfig): boolean {
|
|
193
|
+
if (!pc.elevenlabsApiKey) return false
|
|
194
|
+
|
|
195
|
+
let dirty = false
|
|
196
|
+
const messages = (config.messages ?? {}) as Record<string, unknown>
|
|
197
|
+
const tts = (messages.tts ?? {}) as Record<string, unknown>
|
|
198
|
+
const elevenlabs = (tts.elevenlabs ?? {}) as Record<string, unknown>
|
|
199
|
+
|
|
200
|
+
// Credentials: enforce
|
|
201
|
+
if (elevenlabs.apiKey !== pc.elevenlabsApiKey) {
|
|
202
|
+
elevenlabs.apiKey = pc.elevenlabsApiKey
|
|
203
|
+
dirty = true
|
|
204
|
+
}
|
|
205
|
+
if (elevenlabs.baseUrl !== 'https://api.elevenlabs.io') {
|
|
206
|
+
elevenlabs.baseUrl = 'https://api.elevenlabs.io'
|
|
207
|
+
dirty = true
|
|
208
|
+
}
|
|
209
|
+
const voiceId = pc.elevenlabsVoiceId ?? ''
|
|
210
|
+
if (elevenlabs.voiceId !== voiceId) {
|
|
211
|
+
elevenlabs.voiceId = voiceId
|
|
212
|
+
dirty = true
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Tuning: set-if-missing
|
|
216
|
+
if (tts.auto === undefined) {
|
|
217
|
+
tts.auto = 'tagged'
|
|
218
|
+
dirty = true
|
|
219
|
+
}
|
|
220
|
+
if (tts.mode === undefined) {
|
|
221
|
+
tts.mode = 'final'
|
|
222
|
+
dirty = true
|
|
223
|
+
}
|
|
224
|
+
if (tts.provider === undefined) {
|
|
225
|
+
tts.provider = 'elevenlabs'
|
|
226
|
+
dirty = true
|
|
227
|
+
}
|
|
228
|
+
if (tts.summaryModel === undefined && pc.defaultModel) {
|
|
229
|
+
tts.summaryModel = `${PROVIDER_NAME}/${pc.defaultModel}`
|
|
230
|
+
dirty = true
|
|
231
|
+
}
|
|
232
|
+
if (tts.modelOverrides === undefined) {
|
|
233
|
+
tts.modelOverrides = {enabled: true}
|
|
234
|
+
dirty = true
|
|
235
|
+
}
|
|
236
|
+
if (tts.maxTextLength === undefined) {
|
|
237
|
+
tts.maxTextLength = 4000
|
|
238
|
+
dirty = true
|
|
239
|
+
}
|
|
240
|
+
if (tts.timeoutMs === undefined) {
|
|
241
|
+
tts.timeoutMs = 30000
|
|
242
|
+
dirty = true
|
|
243
|
+
}
|
|
244
|
+
if (tts.prefsPath === undefined) {
|
|
245
|
+
tts.prefsPath = '~/.openclaw/settings/tts.json'
|
|
246
|
+
dirty = true
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// elevenlabs tuning: set-if-missing
|
|
250
|
+
if (elevenlabs.modelId === undefined) {
|
|
251
|
+
elevenlabs.modelId = 'eleven_multilingual_v2'
|
|
252
|
+
dirty = true
|
|
253
|
+
}
|
|
254
|
+
if (elevenlabs.seed === undefined) {
|
|
255
|
+
elevenlabs.seed = 42
|
|
256
|
+
dirty = true
|
|
257
|
+
}
|
|
258
|
+
if (elevenlabs.applyTextNormalization === undefined) {
|
|
259
|
+
elevenlabs.applyTextNormalization = 'auto'
|
|
260
|
+
dirty = true
|
|
261
|
+
}
|
|
262
|
+
if (elevenlabs.languageCode === undefined) {
|
|
263
|
+
elevenlabs.languageCode = 'en'
|
|
264
|
+
dirty = true
|
|
265
|
+
}
|
|
266
|
+
if (elevenlabs.voiceSettings === undefined) {
|
|
267
|
+
elevenlabs.voiceSettings = {
|
|
268
|
+
stability: 0.5,
|
|
269
|
+
similarityBoost: 0.75,
|
|
270
|
+
style: 0.0,
|
|
271
|
+
useSpeakerBoost: true,
|
|
272
|
+
speed: 1.0,
|
|
273
|
+
}
|
|
274
|
+
dirty = true
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (dirty) {
|
|
278
|
+
tts.elevenlabs = elevenlabs
|
|
279
|
+
messages.tts = tts
|
|
280
|
+
config.messages = messages
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return dirty
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function patchWebSearch(config: Record<string, unknown>, pc: ConfigPluginConfig): boolean {
|
|
287
|
+
if (!pc.braveSearchApiKey) return false
|
|
288
|
+
|
|
289
|
+
let dirty = false
|
|
290
|
+
const tools = (config.tools ?? {}) as Record<string, unknown>
|
|
291
|
+
const web = (tools.web ?? {}) as Record<string, unknown>
|
|
292
|
+
const search = (web.search ?? {}) as Record<string, unknown>
|
|
293
|
+
|
|
294
|
+
// Credentials: enforce
|
|
295
|
+
if (search.provider !== 'brave') {
|
|
296
|
+
search.provider = 'brave'
|
|
297
|
+
dirty = true
|
|
298
|
+
}
|
|
299
|
+
if (search.apiKey !== pc.braveSearchApiKey) {
|
|
300
|
+
search.apiKey = pc.braveSearchApiKey
|
|
301
|
+
dirty = true
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Tuning: set-if-missing
|
|
305
|
+
if (search.maxResults === undefined) {
|
|
306
|
+
search.maxResults = 5
|
|
307
|
+
dirty = true
|
|
308
|
+
}
|
|
309
|
+
if (search.timeoutSeconds === undefined) {
|
|
310
|
+
search.timeoutSeconds = 30
|
|
311
|
+
dirty = true
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (dirty) {
|
|
315
|
+
web.search = search
|
|
316
|
+
tools.web = web
|
|
317
|
+
config.tools = tools
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return dirty
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function patchSession(config: Record<string, unknown>): boolean {
|
|
324
|
+
let dirty = false
|
|
325
|
+
|
|
326
|
+
// session.dmScope: enforce
|
|
327
|
+
const session = (config.session ?? {}) as Record<string, unknown>
|
|
328
|
+
if (session.dmScope !== 'per-channel-peer') {
|
|
329
|
+
session.dmScope = 'per-channel-peer'
|
|
330
|
+
config.session = session
|
|
331
|
+
dirty = true
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// tools.sessions.visibility: enforce
|
|
335
|
+
const tools = (config.tools ?? {}) as Record<string, unknown>
|
|
336
|
+
const sessions = (tools.sessions ?? {}) as Record<string, unknown>
|
|
337
|
+
if (sessions.visibility !== 'agent') {
|
|
338
|
+
sessions.visibility = 'agent'
|
|
339
|
+
tools.sessions = sessions
|
|
340
|
+
config.tools = tools
|
|
341
|
+
dirty = true
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return dirty
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
// Entry point — single read, patch all, single write
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
export function setupConfig(api: PluginApi): void {
|
|
352
|
+
const stateDir = resolveStateDir(api)
|
|
353
|
+
if (!stateDir) {
|
|
354
|
+
api.logger.warn('Cannot resolve state dir — config setup skipped.')
|
|
355
|
+
return
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const configPath = path.join(stateDir, 'openclaw.json')
|
|
359
|
+
const config = readOpenclawConfig(configPath)
|
|
360
|
+
const pc = toPC(api)
|
|
361
|
+
|
|
362
|
+
let dirty = false
|
|
363
|
+
dirty = patchAgent(config, pc) || dirty
|
|
364
|
+
dirty = patchGateway(config) || dirty
|
|
365
|
+
dirty = patchBrowser(config) || dirty
|
|
366
|
+
dirty = patchSessionMainKey(config, pc) || dirty
|
|
367
|
+
dirty = patchSession(config) || dirty
|
|
368
|
+
dirty = patchTts(config, pc) || dirty
|
|
369
|
+
dirty = patchWebSearch(config, pc) || dirty
|
|
370
|
+
dirty = patchModelGateway(config, api) || dirty
|
|
371
|
+
|
|
372
|
+
if (!dirty) {
|
|
373
|
+
api.logger.info('Config setup: no changes needed.')
|
|
374
|
+
return
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
try {
|
|
378
|
+
writeOpenclawConfig(configPath, config)
|
|
379
|
+
api.logger.info('Config setup: patched openclaw.json.')
|
|
380
|
+
} catch (err) {
|
|
381
|
+
api.logger.error(`Config setup failed: ${(err as Error).message}`)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
@@ -274,21 +274,15 @@ describe('shouldSkipPushForMessage', () => {
|
|
|
274
274
|
expect(shouldSkipPushForMessage(' NO_REPLY ')).toBe('silent reply')
|
|
275
275
|
})
|
|
276
276
|
|
|
277
|
-
test('skips heartbeat ack
|
|
277
|
+
test('skips short heartbeat ack', () => {
|
|
278
278
|
expect(shouldSkipPushForMessage('HEARTBEAT_OK')).toBe('heartbeat ack')
|
|
279
|
-
expect(shouldSkipPushForMessage('HEARTBEAT_OK
|
|
280
|
-
expect(shouldSkipPushForMessage('HEARTBEAT_OK\n')).toBe('heartbeat ack')
|
|
279
|
+
expect(shouldSkipPushForMessage('All good. HEARTBEAT_OK')).toBe('heartbeat ack')
|
|
281
280
|
})
|
|
282
281
|
|
|
283
|
-
test('
|
|
284
|
-
expect(shouldSkipPushForMessage('All good. HEARTBEAT_OK')).toBeNull()
|
|
285
|
-
expect(shouldSkipPushForMessage('Nothing to report. HEARTBEAT_OK。')).toBeNull()
|
|
286
|
-
})
|
|
287
|
-
|
|
288
|
-
test('does NOT skip verbose heartbeat response with content before HEARTBEAT_OK', () => {
|
|
282
|
+
test('skips verbose heartbeat ack ending with HEARTBEAT_OK', () => {
|
|
289
283
|
const verbose =
|
|
290
284
|
'The user said hello recently. Looking at HEARTBEAT.md checklist: nothing needs attention. HEARTBEAT_OK'
|
|
291
|
-
expect(shouldSkipPushForMessage(verbose)).
|
|
285
|
+
expect(shouldSkipPushForMessage(verbose)).toBe('heartbeat ack')
|
|
292
286
|
})
|
|
293
287
|
|
|
294
288
|
test('does not skip message mentioning HEARTBEAT_OK mid-text', () => {
|
|
@@ -297,6 +291,10 @@ describe('shouldSkipPushForMessage', () => {
|
|
|
297
291
|
).toBeNull()
|
|
298
292
|
})
|
|
299
293
|
|
|
294
|
+
test('skips heartbeat ack with non-ASCII trailing punctuation', () => {
|
|
295
|
+
expect(shouldSkipPushForMessage('Nothing to report. HEARTBEAT_OK。')).toBe('heartbeat ack')
|
|
296
|
+
})
|
|
297
|
+
|
|
300
298
|
test('skips system prompt leak', () => {
|
|
301
299
|
expect(
|
|
302
300
|
shouldSkipPushForMessage('Here is some Conversation info (untrusted metadata) text'),
|
|
@@ -358,29 +356,6 @@ describe('offline-push with filtered messages', () => {
|
|
|
358
356
|
})
|
|
359
357
|
})
|
|
360
358
|
|
|
361
|
-
test('sends push for meaningful heartbeat response and strips HEARTBEAT_OK from body', async () => {
|
|
362
|
-
const {api, logs, handlers} = createMockApi()
|
|
363
|
-
registerOfflinePush(api)
|
|
364
|
-
|
|
365
|
-
await handlers.get('agent_end')!(
|
|
366
|
-
{
|
|
367
|
-
messages: [
|
|
368
|
-
{
|
|
369
|
-
role: 'assistant',
|
|
370
|
-
content: 'Hey! Just checking in — saw some interesting news today.\n\nHEARTBEAT_OK',
|
|
371
|
-
},
|
|
372
|
-
],
|
|
373
|
-
},
|
|
374
|
-
{sessionKey: 'agent:clawly:main'},
|
|
375
|
-
)
|
|
376
|
-
|
|
377
|
-
expect(logs).toContainEqual({
|
|
378
|
-
level: 'info',
|
|
379
|
-
msg: expect.stringContaining('notified (session=agent:clawly:main)'),
|
|
380
|
-
})
|
|
381
|
-
expect(lastPushOpts?.body).toBe('Hey! Just checking in — saw some interesting news today.')
|
|
382
|
-
})
|
|
383
|
-
|
|
384
359
|
test('sends push for normal message text', async () => {
|
|
385
360
|
const {api, logs, handlers} = createMockApi()
|
|
386
361
|
registerOfflinePush(api)
|
package/gateway/offline-push.ts
CHANGED
|
@@ -79,11 +79,8 @@ export function shouldSkipPushForMessage(text: string): string | null {
|
|
|
79
79
|
// Agent sentinel "nothing to say" — mobile hides as "silentReply"
|
|
80
80
|
if (trimmed === 'NO_REPLY') return 'silent reply'
|
|
81
81
|
|
|
82
|
-
// Heartbeat acknowledgment (HEARTBEAT_OK as ending sentinel) —
|
|
83
|
-
if (/HEARTBEAT_OK[\p{P}\s]*$/u.test(text))
|
|
84
|
-
const stripped = text.replace(/HEARTBEAT_OK[\p{P}\s]*$/u, '').trim()
|
|
85
|
-
if (stripped.length === 0) return 'heartbeat ack'
|
|
86
|
-
}
|
|
82
|
+
// Heartbeat acknowledgment (HEARTBEAT_OK as ending sentinel) — mobile hides as "heartbeatAck"
|
|
83
|
+
if (/HEARTBEAT_OK[\p{P}\s]*$/u.test(text)) return 'heartbeat ack'
|
|
87
84
|
|
|
88
85
|
// Agent echoed system prompt metadata — mobile hides as "systemPromptLeak"
|
|
89
86
|
if (text.includes('Conversation info (untrusted metadata)')) return 'system prompt leak'
|
|
@@ -121,9 +118,8 @@ export function registerOfflinePush(api: PluginApi) {
|
|
|
121
118
|
return
|
|
122
119
|
}
|
|
123
120
|
|
|
124
|
-
const
|
|
125
|
-
const
|
|
126
|
-
const body = preview || 'Your response is ready'
|
|
121
|
+
const preview = fullText && fullText.length > 140 ? `${fullText.slice(0, 140)}…` : fullText
|
|
122
|
+
const body = preview ?? 'Your response is ready'
|
|
127
123
|
|
|
128
124
|
const sent = await sendPushNotification(
|
|
129
125
|
{
|
package/index.ts
CHANGED
|
@@ -32,13 +32,12 @@
|
|
|
32
32
|
import {registerAutoPair} from './auto-pair'
|
|
33
33
|
import {registerCalendar} from './calendar'
|
|
34
34
|
import {registerCommands} from './command'
|
|
35
|
+
import {setupConfig} from './config-setup'
|
|
35
36
|
import {registerCronHook} from './cron-hook'
|
|
36
37
|
import {registerEmail} from './email'
|
|
37
38
|
import {registerGateway} from './gateway'
|
|
38
39
|
import {getGatewayConfig} from './gateway-fetch'
|
|
39
|
-
import {setupModelGateway} from './model-gateway-setup'
|
|
40
40
|
import {registerOutboundHook, registerOutboundHttpRoute, registerOutboundMethods} from './outbound'
|
|
41
|
-
import {setupSession} from './session-setup'
|
|
42
41
|
import {registerSkillCommandRestore} from './skill-command-restore'
|
|
43
42
|
import {registerTools} from './tools'
|
|
44
43
|
|
|
@@ -184,10 +183,9 @@ export default {
|
|
|
184
183
|
registerCommands(api)
|
|
185
184
|
registerTools(api)
|
|
186
185
|
registerCronHook(api)
|
|
186
|
+
setupConfig(api)
|
|
187
187
|
registerGateway(api)
|
|
188
188
|
registerAutoPair(api)
|
|
189
|
-
setupModelGateway(api)
|
|
190
|
-
setupSession(api)
|
|
191
189
|
|
|
192
190
|
// Email & calendar (optional — requires skillGatewayBaseUrl + skillGatewayToken in config)
|
|
193
191
|
const gw = getGatewayConfig(api)
|
package/model-gateway-setup.ts
CHANGED
|
@@ -83,16 +83,7 @@ export function writeOpenclawConfig(configPath: string, config: Record<string, u
|
|
|
83
83
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n')
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
export function
|
|
87
|
-
const stateDir = resolveStateDir(api)
|
|
88
|
-
if (!stateDir) {
|
|
89
|
-
api.logger.warn('Cannot resolve state dir — model gateway setup skipped.')
|
|
90
|
-
return
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const configPath = path.join(stateDir, 'openclaw.json')
|
|
94
|
-
const config = readOpenclawConfig(configPath)
|
|
95
|
-
|
|
86
|
+
export function patchModelGateway(config: Record<string, unknown>, api: PluginApi): boolean {
|
|
96
87
|
// If provider already exists, check if extra models or aliases need updating.
|
|
97
88
|
// This runs before the credentials check because provisioned sprites have
|
|
98
89
|
// credentials in openclaw.json directly, not in pluginConfig.
|
|
@@ -150,16 +141,11 @@ export function setupModelGateway(api: PluginApi): void {
|
|
|
150
141
|
defaults.models = existingAliases
|
|
151
142
|
agents.defaults = defaults
|
|
152
143
|
config.agents = agents
|
|
153
|
-
|
|
154
|
-
writeOpenclawConfig(configPath, config)
|
|
155
|
-
api.logger.info(`Model gateway updated: appended missing extra models/aliases.`)
|
|
156
|
-
} catch (err) {
|
|
157
|
-
api.logger.error(`Failed to update model gateway: ${(err as Error).message}`)
|
|
158
|
-
}
|
|
144
|
+
api.logger.info(`Model gateway updated: appended missing extra models/aliases.`)
|
|
159
145
|
} else {
|
|
160
146
|
api.logger.info('Model gateway provider already configured.')
|
|
161
147
|
}
|
|
162
|
-
return
|
|
148
|
+
return dirty
|
|
163
149
|
}
|
|
164
150
|
|
|
165
151
|
// No existing provider — need pluginConfig credentials to create one
|
|
@@ -170,7 +156,7 @@ export function setupModelGateway(api: PluginApi): void {
|
|
|
170
156
|
|
|
171
157
|
if (!baseUrl || !token) {
|
|
172
158
|
api.logger.info('Model gateway not configured (missing baseUrl or token), skipping.')
|
|
173
|
-
return
|
|
159
|
+
return false
|
|
174
160
|
}
|
|
175
161
|
|
|
176
162
|
// Derive model IDs from agents.defaults
|
|
@@ -187,7 +173,7 @@ export function setupModelGateway(api: PluginApi): void {
|
|
|
187
173
|
|
|
188
174
|
if (!defaultModel) {
|
|
189
175
|
api.logger.warn('No default model found in agents.defaults — model gateway setup skipped.')
|
|
190
|
-
return
|
|
176
|
+
return false
|
|
191
177
|
}
|
|
192
178
|
|
|
193
179
|
const defaultModels =
|
|
@@ -233,9 +219,25 @@ export function setupModelGateway(api: PluginApi): void {
|
|
|
233
219
|
agents.defaults = defaults
|
|
234
220
|
config.agents = agents
|
|
235
221
|
|
|
222
|
+
api.logger.info(`Model gateway provider configured: ${baseUrl} with ${models.length} model(s).`)
|
|
223
|
+
return true
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Standalone wrapper — reads config, patches, writes. Used by tests. */
|
|
227
|
+
export function setupModelGateway(api: PluginApi): void {
|
|
228
|
+
const stateDir = resolveStateDir(api)
|
|
229
|
+
if (!stateDir) {
|
|
230
|
+
api.logger.warn('Cannot resolve state dir — model gateway setup skipped.')
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const configPath = path.join(stateDir, 'openclaw.json')
|
|
235
|
+
const config = readOpenclawConfig(configPath)
|
|
236
|
+
|
|
237
|
+
if (!patchModelGateway(config, api)) return
|
|
238
|
+
|
|
236
239
|
try {
|
|
237
240
|
writeOpenclawConfig(configPath, config)
|
|
238
|
-
api.logger.info(`Model gateway provider configured: ${baseUrl} with ${models.length} model(s).`)
|
|
239
241
|
} catch (err) {
|
|
240
242
|
api.logger.error(`Failed to setup model gateway: ${(err as Error).message}`)
|
|
241
243
|
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
},
|
|
39
39
|
"configSchema": {
|
|
40
40
|
"type": "object",
|
|
41
|
-
"additionalProperties":
|
|
41
|
+
"additionalProperties": true,
|
|
42
42
|
"properties": {
|
|
43
43
|
"memoryDir": { "type": "string", "minLength": 1 },
|
|
44
44
|
"bin": { "type": "string", "minLength": 1 },
|
|
@@ -49,7 +49,16 @@
|
|
|
49
49
|
"skillGatewayBaseUrl": { "type": "string" },
|
|
50
50
|
"skillGatewayToken": { "type": "string" },
|
|
51
51
|
"modelGatewayBaseUrl": { "type": "string" },
|
|
52
|
-
"modelGatewayToken": { "type": "string" }
|
|
52
|
+
"modelGatewayToken": { "type": "string" },
|
|
53
|
+
"agentId": { "type": "string" },
|
|
54
|
+
"agentName": { "type": "string" },
|
|
55
|
+
"workspaceDir": { "type": "string" },
|
|
56
|
+
"defaultModel": { "type": "string" },
|
|
57
|
+
"defaultImageModel": { "type": "string" },
|
|
58
|
+
"sessionMainKey": { "type": "string" },
|
|
59
|
+
"elevenlabsApiKey": { "type": "string" },
|
|
60
|
+
"elevenlabsVoiceId": { "type": "string" },
|
|
61
|
+
"braveSearchApiKey": { "type": "string" }
|
|
53
62
|
},
|
|
54
63
|
"required": []
|
|
55
64
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@2en/clawly-plugins",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.23.0",
|
|
4
4
|
"module": "index.ts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -19,12 +19,12 @@
|
|
|
19
19
|
"index.ts",
|
|
20
20
|
"auto-pair.ts",
|
|
21
21
|
"calendar.ts",
|
|
22
|
+
"config-setup.ts",
|
|
22
23
|
"cron-hook.ts",
|
|
23
24
|
"email.ts",
|
|
24
25
|
"gateway-fetch.ts",
|
|
25
26
|
"outbound.ts",
|
|
26
27
|
"model-gateway-setup.ts",
|
|
27
|
-
"session-setup.ts",
|
|
28
28
|
"skill-command-restore.ts",
|
|
29
29
|
"openclaw.plugin.json"
|
|
30
30
|
],
|
package/session-setup.ts
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* On plugin init, patches openclaw.json to:
|
|
3
|
-
* 1. Set `session.dmScope` to "per-channel-peer" — isolates Telegram (and
|
|
4
|
-
* other channel) DMs from the Clawly mobile session.
|
|
5
|
-
* 2. Set `tools.sessions.visibility` to "agent" — lets the agent read
|
|
6
|
-
* conversation history from other sessions under the same agent (e.g.
|
|
7
|
-
* reference recent Telegram chats from the mobile session).
|
|
8
|
-
*
|
|
9
|
-
* Runs synchronously during plugin registration, same pattern as
|
|
10
|
-
* model-gateway-setup.ts.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import path from 'node:path'
|
|
14
|
-
|
|
15
|
-
import type {PluginApi} from './index'
|
|
16
|
-
import {readOpenclawConfig, resolveStateDir, writeOpenclawConfig} from './model-gateway-setup'
|
|
17
|
-
|
|
18
|
-
const DESIRED_DM_SCOPE = 'per-channel-peer'
|
|
19
|
-
const DESIRED_SESSION_VISIBILITY = 'agent'
|
|
20
|
-
|
|
21
|
-
export function setupSession(api: PluginApi): void {
|
|
22
|
-
const stateDir = resolveStateDir(api)
|
|
23
|
-
if (!stateDir) {
|
|
24
|
-
api.logger.warn('Cannot resolve state dir — session setup skipped.')
|
|
25
|
-
return
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const configPath = path.join(stateDir, 'openclaw.json')
|
|
29
|
-
const config = readOpenclawConfig(configPath)
|
|
30
|
-
|
|
31
|
-
let dirty = false
|
|
32
|
-
|
|
33
|
-
// 1. dmScope
|
|
34
|
-
const session = ((config.session as Record<string, unknown>) ?? {}) as Record<string, unknown>
|
|
35
|
-
if (session.dmScope !== DESIRED_DM_SCOPE) {
|
|
36
|
-
session.dmScope = DESIRED_DM_SCOPE
|
|
37
|
-
config.session = session
|
|
38
|
-
dirty = true
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// 2. tools.sessions.visibility
|
|
42
|
-
const tools = ((config.tools as Record<string, unknown>) ?? {}) as Record<string, unknown>
|
|
43
|
-
const sessions = ((tools.sessions as Record<string, unknown>) ?? {}) as Record<string, unknown>
|
|
44
|
-
if (sessions.visibility !== DESIRED_SESSION_VISIBILITY) {
|
|
45
|
-
sessions.visibility = DESIRED_SESSION_VISIBILITY
|
|
46
|
-
tools.sessions = sessions
|
|
47
|
-
config.tools = tools
|
|
48
|
-
dirty = true
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
if (!dirty) return
|
|
52
|
-
|
|
53
|
-
try {
|
|
54
|
-
writeOpenclawConfig(configPath, config)
|
|
55
|
-
api.logger.info(
|
|
56
|
-
`Session: dmScope="${DESIRED_DM_SCOPE}", tools.sessions.visibility="${DESIRED_SESSION_VISIBILITY}".`,
|
|
57
|
-
)
|
|
58
|
-
} catch (err) {
|
|
59
|
-
api.logger.error(`Failed to update session config: ${(err as Error).message}`)
|
|
60
|
-
}
|
|
61
|
-
}
|