@2en/clawly-plugins 1.32.0-beta.2 → 1.33.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/auto-pair.ts CHANGED
@@ -37,7 +37,15 @@ export function registerAutoPair(api: PluginApi) {
37
37
 
38
38
  api.on('gateway_start', async () => {
39
39
  try {
40
- sdk = await import('openclaw/plugin-sdk')
40
+ // v2026.3.23+ moved pairing functions to device-bootstrap subpath;
41
+ // older versions export them from the main plugin-sdk index.
42
+ try {
43
+ sdk = await import('openclaw/plugin-sdk/device-bootstrap')
44
+ } catch (err: unknown) {
45
+ const code = (err as NodeJS.ErrnoException)?.code
46
+ if (code !== 'ERR_MODULE_NOT_FOUND' && code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') throw err
47
+ sdk = await import('openclaw/plugin-sdk')
48
+ }
41
49
  } catch {
42
50
  api.logger.warn('auto-pair: openclaw/plugin-sdk not available, skipping')
43
51
  return
@@ -77,6 +77,13 @@
77
77
  contextWindow: 196608,
78
78
  maxTokens: 196608,
79
79
  },
80
+ {
81
+ id: "qwen/qwen3.5-flash-02-23",
82
+ name: "qwen/qwen3.5-flash-02-23",
83
+ input: ["text", "image"],
84
+ contextWindow: 1000000,
85
+ maxTokens: 65536,
86
+ },
80
87
  {
81
88
  id: "qwen/qwen3.5-plus-02-15",
82
89
  name: "qwen/qwen3.5-plus-02-15",
package/config-setup.ts CHANGED
@@ -236,6 +236,66 @@ export function patchBrowser(config: OpenClawConfig): boolean {
236
236
  // patchMemorySearch — migrated to clawly-config-defaults.json5
237
237
  // patchHeartbeat — migrated to clawly-config-defaults.json5
238
238
 
239
+ /** Fields enforced by patchModelDefinitions on every gateway restart. */
240
+ const MODEL_ENFORCED_KEYS = ['contextWindow', 'maxTokens', 'input'] as const
241
+
242
+ /** Shallow equality — only supports primitives and arrays of primitives. */
243
+ function valuesEqual(a: unknown, b: unknown): boolean {
244
+ if (a === b) return true
245
+ if (Array.isArray(a) && Array.isArray(b)) {
246
+ return a.length === b.length && a.every((v, i) => v === b[i])
247
+ }
248
+ return false
249
+ }
250
+
251
+ /**
252
+ * Enforce model definitions from defaults onto existing provider model arrays.
253
+ *
254
+ * deepMergeDefaults skips arrays ("config wins"), so stale model entries
255
+ * (e.g. wrong contextWindow from a previous auto target) persist forever.
256
+ * This patches each existing entry's enforced fields to match the canonical
257
+ * defaults from clawly-config-defaults.json5.
258
+ */
259
+ export function patchModelDefinitions(
260
+ config: OpenClawConfig,
261
+ defaults: Record<string, unknown>,
262
+ ): boolean {
263
+ const defaultProviders = defaults.models as Record<string, unknown> | undefined
264
+ const defaultGw = (defaultProviders?.providers as Record<string, unknown> | undefined)?.[
265
+ PROVIDER_NAME
266
+ ] as Record<string, unknown> | undefined
267
+ const defaultModels = defaultGw?.models as Array<Record<string, unknown>> | undefined
268
+ if (!Array.isArray(defaultModels) || defaultModels.length === 0) return false
269
+
270
+ const configGw = config.models?.providers?.[PROVIDER_NAME] as Record<string, unknown> | undefined
271
+ const configModels = configGw?.models as Array<Record<string, unknown>> | undefined
272
+ if (!Array.isArray(configModels) || configModels.length === 0) return false
273
+
274
+ const defaultsById = new Map<string, Record<string, unknown>>()
275
+ for (const m of defaultModels) {
276
+ if (typeof m.id === 'string') defaultsById.set(m.id, m)
277
+ }
278
+
279
+ let dirty = false
280
+ for (const model of configModels) {
281
+ const id = model.id
282
+ if (typeof id !== 'string') continue
283
+ const canonical = defaultsById.get(id)
284
+ if (!canonical) continue
285
+
286
+ for (const key of MODEL_ENFORCED_KEYS) {
287
+ const canonicalVal = canonical[key]
288
+ if (canonicalVal === undefined) continue
289
+ if (!valuesEqual(model[key], canonicalVal)) {
290
+ model[key] = Array.isArray(canonicalVal) ? [...canonicalVal] : canonicalVal
291
+ dirty = true
292
+ }
293
+ }
294
+ }
295
+
296
+ return dirty
297
+ }
298
+
239
299
  // TODO: Re-enable patchToolPolicy once rollback-safe (deny is sticky — rolling back
240
300
  // the plugin leaves web_search permanently denied). Tracked in ENG-1493.
241
301
  // export function patchToolPolicy(config: Record<string, unknown>, pc: ConfigPluginConfig): boolean {
@@ -497,6 +557,42 @@ function loadDefaults(stateDir: string): Record<string, unknown> | null {
497
557
  }
498
558
  }
499
559
 
560
+ /**
561
+ * Ensure plugins.allow lists every installed non-bundled plugin.
562
+ *
563
+ * OpenClaw v2026.3+ enforces an explicit allowlist when non-bundled plugins
564
+ * are discovered in the extensions directory. Without this, sessions.resolve
565
+ * fails — which breaks cron delivery (the delivery step resolves the main
566
+ * session to inject results).
567
+ *
568
+ * We derive the set from plugins.installs keys (written by `openclaw plugins
569
+ * install`) plus 'clawly-plugins' as a baseline. User-installed plugins
570
+ * (e.g. openclaw-weixin) are picked up automatically because their install
571
+ * records land in plugins.installs.
572
+ */
573
+ export function patchPluginsAllow(config: OpenClawConfig): boolean {
574
+ const plugins = (config.plugins ?? {}) as Record<string, unknown>
575
+ const installs = asObj(plugins.installs)
576
+
577
+ // Collect all installed plugin ids; always include clawly-plugins
578
+ const required = new Set<string>(['clawly-plugins'])
579
+ for (const id of Object.keys(installs)) {
580
+ required.add(id)
581
+ }
582
+
583
+ const existing = Array.isArray(plugins.allow) ? (plugins.allow as string[]) : []
584
+ const existingSet = new Set(existing)
585
+
586
+ // Check if the allowlist already contains all required ids
587
+ const missing = [...required].filter((id) => !existingSet.has(id))
588
+ if (missing.length === 0) return false
589
+
590
+ // Append missing ids (preserve any manually added entries)
591
+ plugins.allow = [...existing, ...missing]
592
+ config.plugins = plugins as OpenClawConfig['plugins']
593
+ return true
594
+ }
595
+
500
596
  function reconcileRuntimeConfig(
501
597
  api: PluginApi,
502
598
  config: OpenClawConfig,
@@ -516,6 +612,7 @@ function reconcileRuntimeConfig(
516
612
  }
517
613
 
518
614
  let dirty = false
615
+ dirty = patchPluginsAllow(config) || dirty
519
616
  dirty = patchEnvVars(config, pc, api) || dirty
520
617
  dirty = patchAgent(config, pc) || dirty
521
618
  dirty = patchGateway(config) || dirty
@@ -525,6 +622,11 @@ function reconcileRuntimeConfig(
525
622
  const defaults = loadDefaults(stateDir)
526
623
  if (defaults) {
527
624
  dirty = deepMergeDefaults(config, defaults) || dirty
625
+ const modelsDirty = patchModelDefinitions(config, defaults)
626
+ if (modelsDirty) {
627
+ api.logger.info('Config setup: patched stale model definitions from defaults.')
628
+ }
629
+ dirty = modelsDirty || dirty
528
630
  } else {
529
631
  api.logger.warn('Config setup: failed to load defaults json5, skipping merge.')
530
632
  }
@@ -0,0 +1,221 @@
1
+ import {beforeEach, describe, expect, mock, test} from 'bun:test'
2
+ import type {PluginApi} from '../types'
3
+
4
+ // ── fs mock ──────────────────────────────────────────────────────
5
+
6
+ const fileSystem = new Map<string, string>()
7
+
8
+ mock.module('node:fs', () => ({
9
+ default: {
10
+ readFileSync: (filePath: string, _encoding: string) => {
11
+ const content = fileSystem.get(filePath)
12
+ if (content === undefined) {
13
+ const err = new Error(`ENOENT: no such file '${filePath}'`) as NodeJS.ErrnoException
14
+ err.code = 'ENOENT'
15
+ throw err
16
+ }
17
+ if (content === '<<EPERM>>') {
18
+ const err = new Error(`EPERM: permission denied '${filePath}'`) as NodeJS.ErrnoException
19
+ err.code = 'EPERM'
20
+ throw err
21
+ }
22
+ return content
23
+ },
24
+ existsSync: (filePath: string) => fileSystem.has(filePath),
25
+ mkdirSync: () => {},
26
+ writeFileSync: () => {},
27
+ unlinkSync: () => {},
28
+ },
29
+ }))
30
+
31
+ import {getAgentIdentity, parseIdentityFields, resolveAgentTitle} from './notification'
32
+
33
+ // ── helpers ──────────────────────────────────────────────────────
34
+
35
+ const logs: {level: string; msg: string}[] = []
36
+
37
+ function createMockApi(stateDir = '/data'): PluginApi {
38
+ return {
39
+ runtime: {state: {resolveStateDir: () => stateDir}},
40
+ logger: {
41
+ info: (msg: string) => logs.push({level: 'info', msg}),
42
+ warn: (msg: string) => logs.push({level: 'warn', msg}),
43
+ error: (msg: string) => logs.push({level: 'error', msg}),
44
+ },
45
+ } as unknown as PluginApi
46
+ }
47
+
48
+ beforeEach(() => {
49
+ fileSystem.clear()
50
+ logs.length = 0
51
+ delete process.env.OPENCLAW_WORKSPACE
52
+ })
53
+
54
+ // ── parseIdentityFields ──────────────────────────────────────────
55
+
56
+ describe('parseIdentityFields', () => {
57
+ test('parses standard IDENTITY.md format', () => {
58
+ const content = [
59
+ '# IDENTITY.md - Who Am I?',
60
+ '',
61
+ '- **Name:** Tigermountain',
62
+ '- **Creature:** AI agent',
63
+ '- **Emoji:** 🐅',
64
+ '- **Avatar:** *(none yet)*',
65
+ ].join('\n')
66
+ expect(parseIdentityFields(content)).toEqual({name: 'Tigermountain', emoji: '🐅'})
67
+ })
68
+
69
+ test('parses without list markers', () => {
70
+ const content = 'Name: Alice\nEmoji: 🦊'
71
+ expect(parseIdentityFields(content)).toEqual({name: 'Alice', emoji: '🦊'})
72
+ })
73
+
74
+ test('strips markdown heading syntax from labels', () => {
75
+ const content = '## Name: HeadingAgent\n## Emoji: 🎯'
76
+ expect(parseIdentityFields(content)).toEqual({name: 'HeadingAgent', emoji: '🎯'})
77
+ })
78
+
79
+ test('returns empty when no name or emoji', () => {
80
+ const content = '- **Creature:** robot\n- **Vibe:** chill'
81
+ expect(parseIdentityFields(content)).toEqual({})
82
+ })
83
+
84
+ test('returns empty for blank content', () => {
85
+ expect(parseIdentityFields('')).toEqual({})
86
+ })
87
+
88
+ test('handles Windows line endings', () => {
89
+ const content = '- **Name:** WinBot\r\n- **Emoji:** 💻\r\n'
90
+ expect(parseIdentityFields(content)).toEqual({name: 'WinBot', emoji: '💻'})
91
+ })
92
+
93
+ test('handles value without markdown wrapping', () => {
94
+ const content = '- **Name:** PlainName'
95
+ expect(parseIdentityFields(content)).toEqual({name: 'PlainName'})
96
+ })
97
+
98
+ test('ignores lines without colon', () => {
99
+ const content = 'No colon here\nName: Valid'
100
+ expect(parseIdentityFields(content)).toEqual({name: 'Valid'})
101
+ })
102
+ })
103
+
104
+ // ── getAgentIdentity ─────────────────────────────────────────────
105
+
106
+ describe('getAgentIdentity', () => {
107
+ test('returns null for undefined agentId', () => {
108
+ expect(getAgentIdentity(undefined, createMockApi())).toBeNull()
109
+ })
110
+
111
+ test('reads identity from convention workspace path', () => {
112
+ fileSystem.set('/data/openclaw.json', '{}')
113
+ fileSystem.set('/data/workspace-clawly/IDENTITY.md', '- **Name:** Tiger\n- **Emoji:** 🐅')
114
+ expect(getAgentIdentity('clawly', createMockApi())).toEqual({name: 'Tiger', emoji: '🐅'})
115
+ })
116
+
117
+ test('uses explicit workspace from agent config', () => {
118
+ fileSystem.set(
119
+ '/data/openclaw.json',
120
+ JSON.stringify({agents: {list: [{id: 'clawly', workspace: '/custom/ws'}]}}),
121
+ )
122
+ fileSystem.set('/custom/ws/IDENTITY.md', '- Name: CustomAgent\n- Emoji: 🎭')
123
+ expect(getAgentIdentity('clawly', createMockApi())).toEqual({
124
+ name: 'CustomAgent',
125
+ emoji: '🎭',
126
+ })
127
+ })
128
+
129
+ test('does not fall back to default agent workspace', () => {
130
+ fileSystem.set(
131
+ '/data/openclaw.json',
132
+ JSON.stringify({
133
+ agents: {list: [{id: 'other', default: true, workspace: '/other/ws'}]},
134
+ }),
135
+ )
136
+ fileSystem.set('/other/ws/IDENTITY.md', '- Name: WrongAgent')
137
+ fileSystem.set('/data/workspace-clawly/IDENTITY.md', '- Name: CorrectAgent')
138
+ expect(getAgentIdentity('clawly', createMockApi())).toEqual({name: 'CorrectAgent'})
139
+ })
140
+
141
+ test('returns null silently when IDENTITY.md missing (ENOENT)', () => {
142
+ fileSystem.set('/data/openclaw.json', '{}')
143
+ expect(getAgentIdentity('clawly', createMockApi())).toBeNull()
144
+ expect(logs.filter((l) => l.level === 'warn')).toHaveLength(0)
145
+ })
146
+
147
+ test('warns when IDENTITY.md exists but has no name/emoji', () => {
148
+ fileSystem.set('/data/openclaw.json', '{}')
149
+ fileSystem.set('/data/workspace-clawly/IDENTITY.md', '- Creature: robot')
150
+ expect(getAgentIdentity('clawly', createMockApi())).toBeNull()
151
+ expect(logs).toContainEqual({
152
+ level: 'warn',
153
+ msg: expect.stringContaining('no name/emoji found'),
154
+ })
155
+ })
156
+
157
+ test('warns on IDENTITY.md read error (non-ENOENT)', () => {
158
+ fileSystem.set('/data/openclaw.json', '{}')
159
+ fileSystem.set('/data/workspace-clawly/IDENTITY.md', '<<EPERM>>')
160
+ expect(getAgentIdentity('clawly', createMockApi())).toBeNull()
161
+ expect(logs).toContainEqual({
162
+ level: 'warn',
163
+ msg: expect.stringContaining('failed to read IDENTITY.md'),
164
+ })
165
+ })
166
+
167
+ test('warns on corrupted openclaw.json (non-ENOENT)', () => {
168
+ fileSystem.set('/data/openclaw.json', '{invalid json')
169
+ fileSystem.set('/data/workspace-clawly/IDENTITY.md', '- Name: Tiger')
170
+ // Still falls back to convention path and reads identity
171
+ expect(getAgentIdentity('clawly', createMockApi())).toEqual({name: 'Tiger'})
172
+ expect(logs).toContainEqual({
173
+ level: 'warn',
174
+ msg: expect.stringContaining('failed to parse openclaw.json'),
175
+ })
176
+ })
177
+
178
+ test('ignores OPENCLAW_WORKSPACE when openclaw.json is readable', () => {
179
+ process.env.OPENCLAW_WORKSPACE = '/env/workspace'
180
+ fileSystem.set('/data/openclaw.json', '{}')
181
+ fileSystem.set('/data/workspace-clawly/IDENTITY.md', '- Name: CorrectAgent')
182
+ fileSystem.set('/env/workspace/IDENTITY.md', '- Name: WrongAgent')
183
+ expect(getAgentIdentity('clawly', createMockApi())).toEqual({name: 'CorrectAgent'})
184
+ })
185
+
186
+ test('ignores OPENCLAW_WORKSPACE even when openclaw.json is missing', () => {
187
+ process.env.OPENCLAW_WORKSPACE = '/env/workspace'
188
+ fileSystem.set('/env/workspace/IDENTITY.md', '- Name: WrongAgent')
189
+ fileSystem.set('/data/workspace-clawly/IDENTITY.md', '- Name: CorrectAgent')
190
+ expect(getAgentIdentity('clawly', createMockApi())).toEqual({name: 'CorrectAgent'})
191
+ })
192
+
193
+ test('returns null when stateDir is empty', () => {
194
+ expect(getAgentIdentity('clawly', createMockApi(''))).toBeNull()
195
+ })
196
+ })
197
+
198
+ // ── resolveAgentTitle ────────────────────────────────────────────
199
+
200
+ describe('resolveAgentTitle', () => {
201
+ test('returns agent name with emoji', () => {
202
+ fileSystem.set('/data/openclaw.json', '{}')
203
+ fileSystem.set('/data/workspace-clawly/IDENTITY.md', '- Name: Tiger\n- Emoji: 🐅')
204
+ expect(resolveAgentTitle('clawly', createMockApi())).toBe('🐅 Tiger')
205
+ })
206
+
207
+ test('falls back to lobster Clawly when no identity', () => {
208
+ fileSystem.set('/data/openclaw.json', '{}')
209
+ expect(resolveAgentTitle('clawly', createMockApi())).toBe('🦞 Clawly')
210
+ })
211
+
212
+ test('uses lobster emoji when name exists but no emoji', () => {
213
+ fileSystem.set('/data/openclaw.json', '{}')
214
+ fileSystem.set('/data/workspace-clawly/IDENTITY.md', '- Name: NoEmoji')
215
+ expect(resolveAgentTitle('clawly', createMockApi())).toBe('🦞 NoEmoji')
216
+ })
217
+
218
+ test('falls back to lobster Clawly when agentId is undefined', () => {
219
+ expect(resolveAgentTitle(undefined, createMockApi())).toBe('🦞 Clawly')
220
+ })
221
+ })
@@ -14,13 +14,9 @@ import fs from 'node:fs'
14
14
  import os from 'node:os'
15
15
  import path from 'node:path'
16
16
 
17
- import {$} from 'zx'
18
17
  import type {PluginApi} from '../types'
19
- import {stripCliLogs} from '../lib/stripCliLogs'
20
18
  import {stripMarkdown} from '../lib/stripMarkdown'
21
19
 
22
- $.verbose = false
23
-
24
20
  /** Extract the first emoji from a string, or null if none found. */
25
21
  const EMOJI_RE =
26
22
  /(?:\p{Emoji_Presentation}|\p{Emoji}\uFE0F)(?:\u200d(?:\p{Emoji_Presentation}|\p{Emoji}\uFE0F))*/u
@@ -120,22 +116,80 @@ function resetBadgeCount(): void {
120
116
  }
121
117
 
122
118
  /**
123
- * Fetch agent display identity (name, emoji) via gateway RPC.
124
- * Returns null on failure so the caller can fall back to defaults.
119
+ * Parse name and emoji from an IDENTITY.md file content.
120
+ * Mirrors the field extraction in openclaw's parseIdentityMarkdown.
125
121
  */
126
- export async function getAgentIdentity(
122
+ export function parseIdentityFields(content: string): {name?: string; emoji?: string} {
123
+ const result: {name?: string; emoji?: string} = {}
124
+ for (const line of content.split(/\r?\n/)) {
125
+ const cleaned = line.trim().replace(/^-\s*/, '')
126
+ const colonIdx = cleaned.indexOf(':')
127
+ if (colonIdx === -1) continue
128
+ const label = cleaned.slice(0, colonIdx).replace(/[*_#]/g, '').trim().toLowerCase()
129
+ const value = cleaned
130
+ .slice(colonIdx + 1)
131
+ .replace(/^[*_]+|[*_]+$/g, '')
132
+ .trim()
133
+ if (!value) continue
134
+ if (label === 'name') result.name = value
135
+ if (label === 'emoji') result.emoji = value
136
+ }
137
+ return result
138
+ }
139
+
140
+ /**
141
+ * Resolve the workspace directory for a given agent by reading openclaw.json.
142
+ * Falls back to `<stateDir>/workspace-<agentId>` when the agent entry has no explicit workspace.
143
+ */
144
+ function resolveWorkspaceDir(api: PluginApi, agentId: string): string | null {
145
+ const stateDir = api.runtime.state.resolveStateDir()
146
+ if (!stateDir) return null
147
+ try {
148
+ const raw = fs.readFileSync(path.join(stateDir, 'openclaw.json'), 'utf-8')
149
+ const config = JSON.parse(raw)
150
+ const agents: Array<{id?: string; default?: boolean; workspace?: string}> = Array.isArray(
151
+ config?.agents?.list,
152
+ )
153
+ ? config.agents.list
154
+ : []
155
+ const agent = agents.find((a) => a.id === agentId)
156
+ if (agent?.workspace) return agent.workspace
157
+ // Config parsed successfully — use convention path, not env var (which may belong to a different agent)
158
+ return path.join(stateDir, `workspace-${agentId}`)
159
+ } catch (err: unknown) {
160
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
161
+ api.logger.warn(
162
+ `notification: failed to parse openclaw.json for agentId=${agentId}: ${err instanceof Error ? err.message : String(err)}`,
163
+ )
164
+ }
165
+ }
166
+ // openclaw.json unreadable — use convention path (stateDir is always available)
167
+ return path.join(stateDir, `workspace-${agentId}`)
168
+ }
169
+
170
+ /**
171
+ * Read agent display identity (name, emoji) directly from IDENTITY.md.
172
+ * Avoids the CLI→WebSocket roundtrip that fails under gateway load.
173
+ */
174
+ export function getAgentIdentity(
127
175
  agentId: string | undefined,
128
- ): Promise<{name?: string; emoji?: string} | null> {
176
+ api: PluginApi,
177
+ ): {name?: string; emoji?: string} | null {
129
178
  if (!agentId) return null
179
+ const wsDir = resolveWorkspaceDir(api, agentId)
180
+ if (!wsDir) return null
130
181
  try {
131
- const rpcParams = JSON.stringify({agentId})
132
- const result = await $`openclaw gateway call agent.identity.get --json --params ${rpcParams}`
133
- const parsed = JSON.parse(stripCliLogs(result.stdout))
134
- return {
135
- name: typeof parsed.name === 'string' ? parsed.name : undefined,
136
- emoji: typeof parsed.emoji === 'string' ? parsed.emoji : undefined,
182
+ const content = fs.readFileSync(path.join(wsDir, 'IDENTITY.md'), 'utf-8')
183
+ const fields = parseIdentityFields(content)
184
+ if (fields.name || fields.emoji) return fields
185
+ api.logger.warn(`notification: IDENTITY.md parsed but no name/emoji found (agentId=${agentId})`)
186
+ return null
187
+ } catch (err) {
188
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
189
+ api.logger.warn(
190
+ `notification: failed to read IDENTITY.md for agentId=${agentId}: ${err instanceof Error ? err.message : String(err)}`,
191
+ )
137
192
  }
138
- } catch {
139
193
  return null
140
194
  }
141
195
  }
@@ -144,8 +198,8 @@ export async function getAgentIdentity(
144
198
  * Build a push notification title from agent identity.
145
199
  * Falls back to "🦞 Clawly" when identity is unavailable.
146
200
  */
147
- export async function resolveAgentTitle(agentId?: string): Promise<string> {
148
- const identity = await getAgentIdentity(agentId)
201
+ export function resolveAgentTitle(agentId: string | undefined, api: PluginApi): string {
202
+ const identity = getAgentIdentity(agentId, api)
149
203
  const emoji = extractEmoji(identity?.emoji) ?? '🦞'
150
204
  return `${emoji} ${identity?.name ?? 'Clawly'}`
151
205
  }
@@ -161,7 +215,7 @@ export async function sendPushNotification(
161
215
  return false
162
216
  }
163
217
 
164
- const title = stripMarkdown(opts.title ?? (await resolveAgentTitle(opts.agentId)))
218
+ const title = stripMarkdown(opts.title ?? resolveAgentTitle(opts.agentId, api))
165
219
  const body = stripMarkdown(opts.body)
166
220
  if (!body) {
167
221
  api.logger.warn('notification: body stripped to empty, skipping push')
@@ -255,7 +309,25 @@ export function registerNotification(api: PluginApi) {
255
309
  respond(true, {cleared: true})
256
310
  })
257
311
 
312
+ api.registerGatewayMethod('clawly.notification.clearToken', async ({respond}) => {
313
+ let fileCleared = true
314
+ try {
315
+ await fs.promises.unlink(TOKEN_FILE)
316
+ } catch (err) {
317
+ // ENOENT is fine — file already gone.
318
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
319
+ fileCleared = false
320
+ api.logger.warn(
321
+ `notification: failed to delete token file: ${err instanceof Error ? err.message : String(err)}`,
322
+ )
323
+ }
324
+ }
325
+ if (fileCleared) resetBadgeCount()
326
+ api.logger.info(`notification: push token cleared (fileCleared=${fileCleared})`)
327
+ respond(fileCleared, {cleared: fileCleared})
328
+ })
329
+
258
330
  api.logger.info(
259
- 'notification: registered clawly.notification.setToken + clawly.notification.send + clawly.notification.clearBadge',
331
+ 'notification: registered clawly.notification.setToken + clawly.notification.send + clawly.notification.clearBadge + clawly.notification.clearToken',
260
332
  )
261
333
  }
@@ -0,0 +1,110 @@
1
+ import {afterEach, describe, expect, mock, test} from 'bun:test'
2
+ import type {PluginApi} from '../types'
3
+ import {
4
+ ensureWeixinBinding,
5
+ resetBindingEnsureInFlight,
6
+ resolveDefaultAgentId,
7
+ } from './weixin-login'
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // resolveDefaultAgentId
11
+ // ---------------------------------------------------------------------------
12
+
13
+ describe('resolveDefaultAgentId', () => {
14
+ test('returns "clawly" when agents.list is empty', () => {
15
+ expect(resolveDefaultAgentId({agents: {list: []}} as any)).toBe('clawly')
16
+ })
17
+
18
+ test('returns "clawly" when agents is undefined', () => {
19
+ expect(resolveDefaultAgentId({} as any)).toBe('clawly')
20
+ })
21
+
22
+ test('returns the default agent id', () => {
23
+ const config = {
24
+ agents: {list: [{id: 'other'}, {id: 'mybot', default: true}]},
25
+ }
26
+ expect(resolveDefaultAgentId(config as any)).toBe('mybot')
27
+ })
28
+
29
+ test('returns first agent when none marked default', () => {
30
+ const config = {agents: {list: [{id: 'alpha'}, {id: 'beta'}]}}
31
+ expect(resolveDefaultAgentId(config as any)).toBe('alpha')
32
+ })
33
+ })
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // ensureWeixinBinding
37
+ // ---------------------------------------------------------------------------
38
+
39
+ function createMockApi(config: Record<string, unknown> = {}) {
40
+ return {
41
+ runtime: {
42
+ config: {
43
+ loadConfig: mock(() => config),
44
+ writeConfigFile: mock(() => Promise.resolve()),
45
+ },
46
+ },
47
+ logger: {
48
+ info: mock(() => {}),
49
+ warn: mock(() => {}),
50
+ },
51
+ } as unknown as PluginApi
52
+ }
53
+
54
+ describe('ensureWeixinBinding', () => {
55
+ afterEach(() => resetBindingEnsureInFlight())
56
+
57
+ test('skips write when binding already exists', async () => {
58
+ const api = createMockApi({
59
+ bindings: [{agentId: 'clawly', match: {channel: 'openclaw-weixin'}}],
60
+ })
61
+ await ensureWeixinBinding(api)
62
+ expect(api.runtime.config.writeConfigFile).not.toHaveBeenCalled()
63
+ })
64
+
65
+ test('writes binding when none exists', async () => {
66
+ const api = createMockApi({agents: {list: [{id: 'clawly', default: true}]}})
67
+ await ensureWeixinBinding(api)
68
+ expect(api.runtime.config.writeConfigFile).toHaveBeenCalledTimes(1)
69
+ const written = (api.runtime.config.writeConfigFile as ReturnType<typeof mock>).mock.calls[0][0]
70
+ expect(written.bindings).toEqual([{agentId: 'clawly', match: {channel: 'openclaw-weixin'}}])
71
+ })
72
+
73
+ test('returns silently when loadConfig throws', async () => {
74
+ const api = createMockApi()
75
+ ;(api.runtime.config.loadConfig as ReturnType<typeof mock>).mockImplementation(() => {
76
+ throw new Error('corrupted')
77
+ })
78
+ await ensureWeixinBinding(api)
79
+ expect(api.runtime.config.writeConfigFile).not.toHaveBeenCalled()
80
+ })
81
+
82
+ test('resets in-flight flag after loadConfig throws', async () => {
83
+ const api = createMockApi()
84
+ ;(api.runtime.config.loadConfig as ReturnType<typeof mock>).mockImplementation(() => {
85
+ throw new Error('corrupted')
86
+ })
87
+ await ensureWeixinBinding(api)
88
+ // Second call should not be blocked
89
+ ;(api.runtime.config.loadConfig as ReturnType<typeof mock>).mockImplementation(() => ({}))
90
+ await ensureWeixinBinding(api)
91
+ expect(api.runtime.config.writeConfigFile).toHaveBeenCalledTimes(1)
92
+ })
93
+
94
+ test('concurrent call is a no-op while in-flight', async () => {
95
+ let resolveWrite!: () => void
96
+ const api = createMockApi({})
97
+ ;(api.runtime.config.writeConfigFile as ReturnType<typeof mock>).mockImplementation(
98
+ () =>
99
+ new Promise<void>((r) => {
100
+ resolveWrite = r
101
+ }),
102
+ )
103
+ const first = ensureWeixinBinding(api)
104
+ // Second call while first is awaiting writeConfigFile
105
+ await ensureWeixinBinding(api)
106
+ expect(api.runtime.config.writeConfigFile).toHaveBeenCalledTimes(1)
107
+ resolveWrite()
108
+ await first
109
+ })
110
+ })
@@ -10,6 +10,7 @@
10
10
  * clawly.weixin.login.wait → { connected, message }
11
11
  */
12
12
 
13
+ import type {OpenClawConfig} from '../types/openclaw'
13
14
  import type {PluginApi} from '../types'
14
15
 
15
16
  const WEIXIN_CHANNEL_ID = 'openclaw-weixin'
@@ -73,6 +74,66 @@ function resolveWeixinPlugin(logger?: {warn: (msg: string) => void}): ChannelPlu
73
74
  }
74
75
  }
75
76
 
77
+ /**
78
+ * Resolve the default agent ID from config (first default agent, or first agent, or 'clawly').
79
+ */
80
+ /** @internal exported for testing */
81
+ export function resolveDefaultAgentId(config: OpenClawConfig): string {
82
+ const list = config.agents?.list
83
+ if (!Array.isArray(list) || list.length === 0) return 'clawly'
84
+ const defaultAgent = list.find((a) => a.default) ?? list[0]
85
+ return defaultAgent?.id ?? 'clawly'
86
+ }
87
+
88
+ /**
89
+ * Ensure a binding exists for openclaw-weixin → default agent.
90
+ * Bindings changes are classified as "none" in OpenClaw's config-reload,
91
+ * so this does NOT trigger a gateway restart.
92
+ */
93
+ /** @internal exported for testing */
94
+ export let bindingEnsureInFlight = false
95
+
96
+ /** @internal reset for testing */
97
+ export function resetBindingEnsureInFlight() {
98
+ bindingEnsureInFlight = false
99
+ }
100
+
101
+ /** @internal exported for testing */
102
+ export async function ensureWeixinBinding(api: PluginApi): Promise<void> {
103
+ if (bindingEnsureInFlight) return
104
+ bindingEnsureInFlight = true
105
+ try {
106
+ let config: OpenClawConfig
107
+ try {
108
+ config = {...(api.runtime.config.loadConfig() as OpenClawConfig)}
109
+ } catch {
110
+ return
111
+ }
112
+
113
+ const bindings = Array.isArray(config.bindings) ? config.bindings : []
114
+ const agentId = resolveDefaultAgentId(config)
115
+ const hasValidBinding = bindings.some(
116
+ (b) => b.match?.channel === WEIXIN_CHANNEL_ID && b.agentId === agentId,
117
+ )
118
+ if (hasValidBinding) return
119
+
120
+ // Remove any stale weixin bindings (pointing to a different agent), then add the current one.
121
+ const filtered = bindings.filter((b) => b.match?.channel !== WEIXIN_CHANNEL_ID)
122
+ config.bindings = [...filtered, {agentId, match: {channel: WEIXIN_CHANNEL_ID}}]
123
+
124
+ try {
125
+ await api.runtime.config.writeConfigFile(config)
126
+ api.logger.info(`weixin-login: created binding ${WEIXIN_CHANNEL_ID} → ${agentId}`)
127
+ } catch (err) {
128
+ api.logger.warn(
129
+ `weixin-login: failed to write binding: ${err instanceof Error ? err.message : String(err)}`,
130
+ )
131
+ }
132
+ } finally {
133
+ bindingEnsureInFlight = false
134
+ }
135
+ }
136
+
76
137
  export function registerWeixinLogin(api: PluginApi) {
77
138
  api.registerGatewayMethod('clawly.weixin.login.start', async ({params, respond}) => {
78
139
  const plugin = resolveWeixinPlugin(api.logger)
@@ -98,6 +159,9 @@ export function registerWeixinLogin(api: PluginApi) {
98
159
  GATEWAY_TIMEOUT_MS,
99
160
  'loginWithQrStart',
100
161
  )
162
+ // Ensure routing binding exists before responding — if the user scans
163
+ // the QR code and WeChat connects, messages need to be routed immediately.
164
+ await ensureWeixinBinding(api)
101
165
  respond(true, result)
102
166
  } catch (err) {
103
167
  respond(false, undefined, {
package/index.ts CHANGED
@@ -55,6 +55,7 @@ import {
55
55
  registerOutboundHttpRoute,
56
56
  registerOutboundMethods,
57
57
  } from './http/file/outbound'
58
+ import {registerMediaUnderstanding} from './media-understanding'
58
59
  import {registerSkillCommandRestore} from './skill-command-restore'
59
60
  import {registerTools} from './tools'
60
61
  import type {PluginApi} from './types'
@@ -83,6 +84,7 @@ export default {
83
84
  registerGateway(api)
84
85
  registerAutoPair(api)
85
86
  registerAutoUpdate(api)
87
+ registerMediaUnderstanding(api)
86
88
 
87
89
  // Email & calendar (optional — requires API base URL + token)
88
90
  const gw = getGatewayConfig(api)
@@ -0,0 +1,44 @@
1
+ import {PROVIDER_NAME} from './model-gateway-setup'
2
+ import type {PluginApi} from './types'
3
+
4
+ type MediaSdk = {
5
+ describeImageWithModel: (...args: unknown[]) => Promise<unknown>
6
+ describeImagesWithModel: (...args: unknown[]) => Promise<unknown>
7
+ }
8
+
9
+ let sdkPromise: Promise<MediaSdk | null> | null = null
10
+
11
+ function loadSdk(logger: PluginApi['logger']): Promise<MediaSdk | null> {
12
+ if (!sdkPromise) {
13
+ sdkPromise = import('openclaw/plugin-sdk/media-understanding').catch((err: unknown) => {
14
+ const code = (err as NodeJS.ErrnoException)?.code
15
+ if (code === 'ERR_MODULE_NOT_FOUND' || code === 'ERR_PACKAGE_PATH_NOT_EXPORTED') {
16
+ logger.warn(
17
+ 'media-understanding: openclaw/plugin-sdk/media-understanding not available, skipping',
18
+ )
19
+ return null
20
+ }
21
+ throw err
22
+ })
23
+ }
24
+ return sdkPromise
25
+ }
26
+
27
+ export function registerMediaUnderstanding(api: PluginApi): void {
28
+ if (!api.registerMediaUnderstandingProvider) return
29
+
30
+ api.registerMediaUnderstandingProvider({
31
+ id: PROVIDER_NAME,
32
+ capabilities: ['image'],
33
+ describeImage: async (...args: unknown[]) => {
34
+ const sdk = await loadSdk(api.logger)
35
+ if (!sdk) return undefined
36
+ return sdk.describeImageWithModel(...args)
37
+ },
38
+ describeImages: async (...args: unknown[]) => {
39
+ const sdk = await loadSdk(api.logger)
40
+ if (!sdk) return undefined
41
+ return sdk.describeImagesWithModel(...args)
42
+ },
43
+ })
44
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2en/clawly-plugins",
3
- "version": "1.32.0-beta.2",
3
+ "version": "1.33.1",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "repository": {
@@ -34,6 +34,7 @@
34
34
  "email.ts",
35
35
  "gateway-fetch.ts",
36
36
  "http",
37
+ "media-understanding.ts",
37
38
  "model-gateway-setup.ts",
38
39
  "resolve-gateway-credentials.ts",
39
40
  "skill-command-restore.ts",
package/types.ts CHANGED
@@ -256,6 +256,14 @@ export type PluginApi = {
256
256
  stop?: (...args: unknown[]) => void | Promise<void>
257
257
  }) => void
258
258
  registerProvider: (provider: Record<string, unknown>) => void
259
+ registerMediaUnderstandingProvider?: (provider: {
260
+ id: string
261
+ capabilities?: Array<'image' | 'audio' | 'video'>
262
+ describeImage?: (...args: unknown[]) => Promise<unknown>
263
+ describeImages?: (...args: unknown[]) => Promise<unknown>
264
+ transcribeAudio?: (...args: unknown[]) => Promise<unknown>
265
+ describeVideo?: (...args: unknown[]) => Promise<unknown>
266
+ }) => void
259
267
  registerContextEngine: (id: string, factory: (...args: unknown[]) => unknown) => void
260
268
  resolvePath: (input: string) => string
261
269
  on: <K extends PluginHookName>(