@2en/clawly-plugins 1.26.0 → 1.26.1-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/config-setup.ts CHANGED
@@ -19,6 +19,7 @@ import fs from 'node:fs'
19
19
  import path from 'node:path'
20
20
 
21
21
  import type {PluginApi} from './index'
22
+ import {autoSanitizeSession} from './gateway/session-sanitize'
22
23
  import {
23
24
  PROVIDER_NAME,
24
25
  patchModelGateway,
@@ -43,8 +44,39 @@ export interface ConfigPluginConfig {
43
44
  posthogHost?: string
44
45
  }
45
46
 
46
- function toPC(api: PluginApi): ConfigPluginConfig {
47
- return (api.pluginConfig ?? {}) as ConfigPluginConfig
47
+ function asObj(value: unknown): Record<string, unknown> {
48
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
49
+ ? (value as Record<string, unknown>)
50
+ : {}
51
+ }
52
+
53
+ function readFilePluginConfig(config: Record<string, unknown>): ConfigPluginConfig {
54
+ const plugins = asObj(config.plugins)
55
+ const entries = asObj(plugins.entries)
56
+ const raw = asObj(entries['clawly-plugins'])
57
+ const fileConfig = raw?.config
58
+ return fileConfig && typeof fileConfig === 'object' && !Array.isArray(fileConfig)
59
+ ? (fileConfig as ConfigPluginConfig)
60
+ : {}
61
+ }
62
+
63
+ function mergeDefinedPluginConfig(
64
+ fileConfig: ConfigPluginConfig,
65
+ runtimeConfig: ConfigPluginConfig,
66
+ ): ConfigPluginConfig {
67
+ const merged = {...fileConfig}
68
+ for (const [key, value] of Object.entries(runtimeConfig)) {
69
+ if (value !== undefined) {
70
+ merged[key as keyof ConfigPluginConfig] = value
71
+ }
72
+ }
73
+ return merged
74
+ }
75
+
76
+ function toPC(api: PluginApi, config: Record<string, unknown>): ConfigPluginConfig {
77
+ const fileConfig = readFilePluginConfig(config)
78
+ const runtimeConfig = (api.pluginConfig ?? {}) as ConfigPluginConfig
79
+ return mergeDefinedPluginConfig(fileConfig, runtimeConfig)
48
80
  }
49
81
 
50
82
  const DEFAULT_MODEL = `${PROVIDER_NAME}/anthropic/claude-sonnet-4.6`
@@ -350,6 +382,58 @@ export function patchWebSearch(config: Record<string, unknown>, pc: ConfigPlugin
350
382
  return dirty
351
383
  }
352
384
 
385
+ export function patchMemorySearch(
386
+ config: Record<string, unknown>,
387
+ pc: ConfigPluginConfig,
388
+ ): boolean {
389
+ if (!pc.modelGatewayBaseUrl || !pc.modelGatewayToken) return false
390
+
391
+ let dirty = false
392
+ const agents = (config.agents ?? {}) as Record<string, unknown>
393
+ const defaults = (agents.defaults ?? {}) as Record<string, unknown>
394
+ const ms = (defaults.memorySearch ?? {}) as Record<string, unknown>
395
+ const remote = (ms.remote ?? {}) as Record<string, unknown>
396
+ const batch = (remote.batch ?? {}) as Record<string, unknown>
397
+
398
+ // Provider: enforce (proxy mimics OpenAI API)
399
+ if (ms.provider !== 'openai') {
400
+ ms.provider = 'openai'
401
+ dirty = true
402
+ }
403
+
404
+ // Model: set-if-missing
405
+ if (ms.model === undefined) {
406
+ ms.model = 'text-embedding-3-small'
407
+ dirty = true
408
+ }
409
+
410
+ // Remote credentials: enforce
411
+ if (remote.baseUrl !== pc.modelGatewayBaseUrl) {
412
+ remote.baseUrl = pc.modelGatewayBaseUrl
413
+ dirty = true
414
+ }
415
+ if (remote.apiKey !== pc.modelGatewayToken) {
416
+ remote.apiKey = pc.modelGatewayToken
417
+ dirty = true
418
+ }
419
+
420
+ // Batch API not supported through proxy: enforce disabled
421
+ if (batch.enabled !== false) {
422
+ batch.enabled = false
423
+ dirty = true
424
+ }
425
+
426
+ if (dirty) {
427
+ remote.batch = batch
428
+ ms.remote = remote
429
+ defaults.memorySearch = ms
430
+ agents.defaults = defaults
431
+ config.agents = agents
432
+ }
433
+
434
+ return dirty
435
+ }
436
+
353
437
  export function patchSession(config: Record<string, unknown>): boolean {
354
438
  let dirty = false
355
439
 
@@ -384,12 +468,6 @@ export function patchSession(config: Record<string, unknown>): boolean {
384
468
  const PLUGIN_ID = 'clawly-plugins'
385
469
  const NPM_PKG_NAME = '@2en/clawly-plugins'
386
470
 
387
- function asObj(value: unknown): Record<string, unknown> {
388
- return value !== null && typeof value === 'object' && !Array.isArray(value)
389
- ? (value as Record<string, unknown>)
390
- : {}
391
- }
392
-
393
471
  /**
394
472
  * Self-healing: reconstruct missing `plugins.installs.clawly-plugins` record.
395
473
  * Older provisioned sprites had this record destroyed by a full-overwrite bug
@@ -469,7 +547,8 @@ function reconcileRuntimeConfig(
469
547
  dirty = patchSession(config) || dirty
470
548
  dirty = patchTts(config, pc) || dirty
471
549
  dirty = patchWebSearch(config, pc) || dirty
472
- dirty = patchModelGateway(config, api) || dirty
550
+ dirty = patchMemorySearch(config, pc) || dirty
551
+ dirty = patchModelGateway(config, api, pc) || dirty
473
552
  return dirty
474
553
  }
475
554
 
@@ -486,21 +565,23 @@ export function setupConfig(api: PluginApi): void {
486
565
 
487
566
  const configPath = path.join(stateDir, 'openclaw.json')
488
567
  const config = readOpenclawConfig(configPath)
489
- const pc = toPC(api)
568
+ const pc = toPC(api, config)
490
569
 
491
570
  let dirty = false
492
571
  dirty = repairLegacyProvisionState(api, config, stateDir) || dirty
493
572
  dirty = reconcileRuntimeConfig(api, config, pc) || dirty
494
573
 
495
- if (!dirty) {
574
+ if (dirty) {
575
+ try {
576
+ writeOpenclawConfig(configPath, config)
577
+ api.logger.info('Config setup: patched openclaw.json.')
578
+ } catch (err) {
579
+ api.logger.error(`Config setup failed: ${(err as Error).message}`)
580
+ }
581
+ } else {
496
582
  api.logger.info('Config setup: no changes needed.')
497
- return
498
583
  }
499
584
 
500
- try {
501
- writeOpenclawConfig(configPath, config)
502
- api.logger.info('Config setup: patched openclaw.json.')
503
- } catch (err) {
504
- api.logger.error(`Config setup failed: ${(err as Error).message}`)
505
- }
585
+ // Best-effort: clear stale delivery fields from the main session on every restart
586
+ autoSanitizeSession(api)
506
587
  }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Timezone sync RPC: writes agents.defaults.userTimezone to openclaw.json
3
+ * without triggering a gateway restart.
4
+ *
5
+ * Methods:
6
+ * - clawly.config.setTimezone({ timezone }) → { changed, timezone }
7
+ */
8
+
9
+ import path from 'node:path'
10
+
11
+ import type {PluginApi} from '../types'
12
+ import {readOpenclawConfig, writeOpenclawConfig} from '../model-gateway-setup'
13
+
14
+ export function registerConfigTimezone(api: PluginApi) {
15
+ api.registerGatewayMethod('clawly.config.setTimezone', async ({params, respond}) => {
16
+ const timezone = typeof params.timezone === 'string' ? params.timezone : ''
17
+ if (!timezone) {
18
+ respond(true, {changed: false, timezone: '', error: 'Missing timezone param'})
19
+ return
20
+ }
21
+
22
+ const stateDir = api.runtime.state.resolveStateDir()
23
+ if (!stateDir) {
24
+ respond(true, {changed: false, timezone, error: 'Cannot resolve state dir'})
25
+ return
26
+ }
27
+
28
+ const configPath = path.join(stateDir, 'openclaw.json')
29
+ const config = readOpenclawConfig(configPath)
30
+
31
+ const agents = (config.agents ?? {}) as Record<string, unknown>
32
+ const defaults = (agents.defaults ?? {}) as Record<string, unknown>
33
+ const current = defaults.userTimezone
34
+
35
+ if (current === timezone) {
36
+ respond(true, {changed: false, timezone})
37
+ return
38
+ }
39
+
40
+ defaults.userTimezone = timezone
41
+ agents.defaults = defaults
42
+ config.agents = agents
43
+
44
+ try {
45
+ writeOpenclawConfig(configPath, config)
46
+ api.logger.info(`config-timezone: set userTimezone to ${timezone}`)
47
+ respond(true, {changed: true, timezone})
48
+ } catch (err) {
49
+ const msg = err instanceof Error ? err.message : String(err)
50
+ api.logger.error(`config-timezone: write failed — ${msg}`)
51
+ respond(true, {changed: false, timezone, error: `Write failed: ${msg}`})
52
+ }
53
+ })
54
+
55
+ api.logger.info('config-timezone: registered clawly.config.setTimezone')
56
+ }
package/gateway/index.ts CHANGED
@@ -3,6 +3,9 @@ import {registerAgentSend} from './agent'
3
3
  import {registerAnalytics} from './analytics'
4
4
  import {registerClawhub2gateway} from './clawhub2gateway'
5
5
  import {registerConfigRepair} from './config-repair'
6
+ import {registerConfigTimezone} from './config-timezone'
7
+
8
+ import {registerSessionSanitize} from './session-sanitize'
6
9
  import {registerCronDelivery} from './cron-delivery'
7
10
  import {registerCronTelemetry} from './cron-telemetry'
8
11
  import {initOtel, shutdownOtel} from './otel'
@@ -54,6 +57,8 @@ export function registerGateway(api: PluginApi) {
54
57
  registerCronTelemetry(api)
55
58
  registerAnalytics(api)
56
59
  registerConfigRepair(api)
60
+ registerConfigTimezone(api)
61
+ registerSessionSanitize(api)
57
62
  registerPairing(api)
58
63
  registerVersion(api)
59
64
  }
@@ -17,6 +17,7 @@ import path from 'node:path'
17
17
  import {$} from 'zx'
18
18
  import type {PluginApi} from '../types'
19
19
  import {stripCliLogs} from '../lib/stripCliLogs'
20
+ import {stripMarkdown} from '../lib/stripMarkdown'
20
21
 
21
22
  $.verbose = false
22
23
 
@@ -151,7 +152,12 @@ export async function sendPushNotification(
151
152
  return false
152
153
  }
153
154
 
154
- const title = opts.title ?? (await resolveAgentTitle(opts.agentId))
155
+ const title = stripMarkdown(opts.title ?? (await resolveAgentTitle(opts.agentId)))
156
+ const body = stripMarkdown(opts.body)
157
+ if (!body) {
158
+ api.logger.warn('notification: body stripped to empty, skipping push')
159
+ return false
160
+ }
155
161
 
156
162
  try {
157
163
  const res = await fetch(EXPO_PUSH_URL, {
@@ -161,7 +167,7 @@ export async function sendPushNotification(
161
167
  to: token,
162
168
  sound: 'default',
163
169
  title,
164
- body: opts.body,
170
+ body,
165
171
  data: opts.data,
166
172
  ...extras,
167
173
  }),
@@ -183,7 +189,7 @@ export async function sendPushNotification(
183
189
  return false
184
190
  }
185
191
 
186
- api.logger.info(`notification: push sent — "${opts.body}"`)
192
+ api.logger.info(`notification: push sent — "${body}"`)
187
193
  return true
188
194
  } catch (err) {
189
195
  api.logger.error(
@@ -374,6 +374,16 @@ describe('shouldSkipPushForMessage', () => {
374
374
  expect(shouldSkipPushForMessage(' NO_REPLY ')).toBe('silent reply')
375
375
  })
376
376
 
377
+ test('skips trailing NO_REPLY with reasoning text (newline-collapsed)', () => {
378
+ // getLastAssistantText collapses "\n" → " ", so these arrive as single-line
379
+ expect(
380
+ shouldSkipPushForMessage('User is online — stopping here, no action needed. NO_REPLY'),
381
+ ).toBe('silent reply')
382
+ expect(shouldSkipPushForMessage('no match in last 24 hours → silent response. NO_REPLY')).toBe(
383
+ 'silent reply',
384
+ )
385
+ })
386
+
377
387
  test('skips heartbeat ack when HEARTBEAT_OK is the only content', () => {
378
388
  expect(shouldSkipPushForMessage('HEARTBEAT_OK')).toBe('heartbeat ack')
379
389
  expect(shouldSkipPushForMessage('HEARTBEAT_OK.')).toBe('heartbeat ack')
@@ -136,8 +136,13 @@ export function shouldSkipPushForMessage(text: string): string | null {
136
136
  // Agent replied with empty content — mobile hides as "emptyAssistant"
137
137
  if (trimmed === '') return 'empty assistant'
138
138
 
139
- // Agent sentinel "nothing to say" — mobile hides as "silentReply"
140
- if (trimmed === 'NO_REPLY') return 'silent reply'
139
+ // Agent sentinel "nothing to say" — mobile hides as "silentReply".
140
+ // Mobile uses (?:^|\n) anchor on raw text; here text is already newline-collapsed
141
+ // by getLastAssistantText, so we match NO_REPLY at string end without line anchor.
142
+ // Known limitation: a message ending with literal "NO_REPLY" as inline text (e.g.
143
+ // "The sentinel is called NO_REPLY") would suppress the push. Acceptable trade-off
144
+ // — the scenario is extremely unlikely and the cost is a missed push, not hidden UI.
145
+ if (/NO_REPLY[\p{P}\s]*$/u.test(text)) return 'silent reply'
141
146
 
142
147
  // Heartbeat acknowledgment (HEARTBEAT_OK as ending sentinel) — only skip if no substantial content before it
143
148
  if (/HEARTBEAT_OK[\p{P}\s]*$/u.test(text)) {
@@ -127,6 +127,22 @@ async function withPluginBackup<T>(
127
127
  }
128
128
  }
129
129
 
130
+ interface UpdateParams {
131
+ pluginId: string
132
+ npmPkgName: string
133
+ strategy: 'force' | 'update' | 'install'
134
+ targetVersion?: string
135
+ restart?: boolean
136
+ /**
137
+ * Skip update if installed version already matches target. Default `true`.
138
+ *
139
+ * Applies to all strategies including `force`. `force` means "use the
140
+ * hardened install flow" (clear plugin config → reinstall), not "always
141
+ * reinstall regardless of version". Set `false` to bypass (testing only).
142
+ */
143
+ skipIfCurrent?: boolean
144
+ }
145
+
130
146
  export function registerPlugins(api: PluginApi) {
131
147
  // ── clawly.plugins.version ──────────────────────────────────────
132
148
 
@@ -194,13 +210,15 @@ export function registerPlugins(api: PluginApi) {
194
210
 
195
211
  // ── clawly.plugins.update ──────────────────────────────────────
196
212
 
197
- api.registerGatewayMethod('clawly.plugins.update', async ({params, respond}) => {
213
+ api.registerGatewayMethod('clawly.plugins.update', async (args) => {
214
+ const {params, respond} = args as {params: Partial<UpdateParams>; respond: typeof args.respond}
198
215
  const pluginId = typeof params.pluginId === 'string' ? params.pluginId.trim() : ''
199
216
  const npmPkgName = typeof params.npmPkgName === 'string' ? params.npmPkgName.trim() : ''
200
217
  const strategy = typeof params.strategy === 'string' ? params.strategy.trim() : ''
201
218
  const targetVersion =
202
219
  typeof params.targetVersion === 'string' ? params.targetVersion.trim() : undefined
203
220
  const restart = params.restart === true
221
+ const skipIfCurrent = params.skipIfCurrent !== false
204
222
 
205
223
  if (!pluginId || !npmPkgName || !strategy) {
206
224
  respond(false, undefined, {
@@ -220,11 +238,41 @@ export function registerPlugins(api: PluginApi) {
220
238
 
221
239
  const installTarget = targetVersion ? `${npmPkgName}@${targetVersion}` : npmPkgName
222
240
 
241
+ // ── Skip if already at target version ──────────────────────
242
+ const stateDir = api.runtime.state.resolveStateDir()
243
+ const installedVersion = stateDir ? readExtensionVersion(stateDir, pluginId) : null
244
+
245
+ if (skipIfCurrent && installedVersion) {
246
+ let resolvedTarget: string | null = null
247
+
248
+ if (targetVersion) {
249
+ // Explicit target — compare directly
250
+ resolvedTarget = targetVersion
251
+ } else {
252
+ // No target — resolve latest from npm
253
+ const npm = await fetchNpmView(npmPkgName)
254
+ if (npm?.version) resolvedTarget = npm.version
255
+ }
256
+
257
+ if (resolvedTarget && installedVersion === resolvedTarget) {
258
+ api.logger.info(
259
+ `plugins: ${pluginId} already at version ${installedVersion}, skipping ${strategy}`,
260
+ )
261
+ captureEvent('plugin.updated', {
262
+ plugin_id: pluginId,
263
+ strategy,
264
+ success: true,
265
+ skipped: true,
266
+ })
267
+ respond(true, {ok: true, strategy, skipped: true})
268
+ return
269
+ }
270
+ }
271
+
223
272
  try {
224
273
  let output = ''
225
274
 
226
275
  if (strategy === 'force') {
227
- const stateDir = api.runtime.state.resolveStateDir()
228
276
  if (!stateDir) throw new Error('cannot resolve openclaw state dir')
229
277
 
230
278
  const configPath = path.join(stateDir, 'openclaw.json')
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Session sanitize RPC: detects and clears stale delivery-context fields
3
+ * from the main webchat session that were left by cross-channel delivery.
4
+ *
5
+ * Methods:
6
+ * - clawly.session.sanitize({ dryRun?, sessionKey? }) → { ok/repaired, issues?, detail }
7
+ */
8
+
9
+ import fs from 'node:fs'
10
+ import path from 'node:path'
11
+
12
+ import type {PluginApi} from '../types'
13
+
14
+ const DEFAULT_AGENT_ID = 'clawly'
15
+
16
+ function mainSessionKey(api: PluginApi): string {
17
+ const agentId =
18
+ (api.pluginConfig as Record<string, unknown> | undefined)?.agentId ?? DEFAULT_AGENT_ID
19
+ return `agent:${agentId}:main`
20
+ }
21
+
22
+ /** Fields on SessionEntry that should be absent (or "webchat") for a webchat-only session. */
23
+ const STALE_CHANNEL_VALUES = new Set([
24
+ 'telegram',
25
+ 'discord',
26
+ 'slack',
27
+ 'signal',
28
+ 'whatsapp',
29
+ 'imessage',
30
+ 'line',
31
+ 'matrix',
32
+ 'msteams',
33
+ 'zalo',
34
+ ])
35
+
36
+ interface SessionsStore {
37
+ sessions: Record<string, Record<string, unknown>>
38
+ }
39
+
40
+ /** Returns the parsed store, or null if the file doesn't exist, or throws on read/parse errors. */
41
+ function readSessionsStore(storePath: string): SessionsStore | null {
42
+ let raw: string
43
+ try {
44
+ raw = fs.readFileSync(storePath, 'utf-8')
45
+ const parsed = JSON.parse(raw)
46
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
47
+ throw new Error('not a JSON object')
48
+ return parsed
49
+ } catch (err: unknown) {
50
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null
51
+ throw err
52
+ }
53
+ }
54
+
55
+ function writeSessionsStore(storePath: string, store: SessionsStore): void {
56
+ const tmpPath = storePath + '.tmp'
57
+ fs.writeFileSync(tmpPath, JSON.stringify(store, null, 2) + '\n')
58
+ fs.renameSync(tmpPath, storePath)
59
+ }
60
+
61
+ /**
62
+ * Check a session entry for stale delivery fields that point to a non-webchat channel.
63
+ * Returns a list of issue descriptions, or empty array if clean.
64
+ */
65
+ function detectIssues(entry: Record<string, unknown>): string[] {
66
+ const issues: string[] = []
67
+
68
+ // deliveryContext.channel
69
+ const dc = entry.deliveryContext as Record<string, unknown> | undefined
70
+ if (dc?.channel && typeof dc.channel === 'string' && STALE_CHANNEL_VALUES.has(dc.channel)) {
71
+ issues.push(`deliveryContext.channel = "${dc.channel}"`)
72
+ }
73
+
74
+ // lastChannel
75
+ if (typeof entry.lastChannel === 'string' && STALE_CHANNEL_VALUES.has(entry.lastChannel)) {
76
+ issues.push(`lastChannel = "${entry.lastChannel}"`)
77
+ }
78
+
79
+ // lastTo, lastAccountId, lastThreadId — should be absent for webchat
80
+ // Values may contain PII (phone numbers, platform user IDs) — omit from logs/UI
81
+ if (entry.lastTo != null) issues.push('lastTo')
82
+ if (entry.lastAccountId != null) issues.push('lastAccountId')
83
+ if (entry.lastThreadId != null) issues.push('lastThreadId')
84
+
85
+ // origin.provider / origin.surface
86
+ const origin = entry.origin as Record<string, unknown> | undefined
87
+ if (
88
+ origin?.provider &&
89
+ typeof origin.provider === 'string' &&
90
+ STALE_CHANNEL_VALUES.has(origin.provider)
91
+ ) {
92
+ issues.push(`origin.provider = "${origin.provider}"`)
93
+ }
94
+ if (
95
+ origin?.surface &&
96
+ typeof origin.surface === 'string' &&
97
+ STALE_CHANNEL_VALUES.has(origin.surface)
98
+ ) {
99
+ issues.push(`origin.surface = "${origin.surface}"`)
100
+ }
101
+
102
+ return issues
103
+ }
104
+
105
+ /** Clear stale delivery fields from a session entry (mutates in place). */
106
+ function repairEntry(entry: Record<string, unknown>): void {
107
+ // Only delete channel-guarded fields when they match a stale channel value
108
+ const dc = entry.deliveryContext as Record<string, unknown> | undefined
109
+ if (dc && typeof dc.channel === 'string' && STALE_CHANNEL_VALUES.has(dc.channel)) {
110
+ delete dc.channel
111
+ if (Object.keys(dc).length === 0) delete entry.deliveryContext
112
+ }
113
+ if (typeof entry.lastChannel === 'string' && STALE_CHANNEL_VALUES.has(entry.lastChannel))
114
+ delete entry.lastChannel
115
+ // lastTo/lastAccountId/lastThreadId are stale for webchat when present
116
+ if (entry.lastTo != null) delete entry.lastTo
117
+ if (entry.lastAccountId != null) delete entry.lastAccountId
118
+ if (entry.lastThreadId != null) delete entry.lastThreadId
119
+
120
+ const origin = entry.origin as Record<string, unknown> | undefined
121
+ if (origin) {
122
+ if (typeof origin.provider === 'string' && STALE_CHANNEL_VALUES.has(origin.provider))
123
+ delete origin.provider
124
+ if (typeof origin.surface === 'string' && STALE_CHANNEL_VALUES.has(origin.surface))
125
+ delete origin.surface
126
+ if (Object.keys(origin).length === 0) delete entry.origin
127
+ }
128
+ }
129
+
130
+ export function registerSessionSanitize(api: PluginApi) {
131
+ // Capture agentId + defaultKey once at registration time so the handler
132
+ // stays consistent with the sessionKey guard even if pluginConfig mutates.
133
+ const agentId =
134
+ ((api.pluginConfig as Record<string, unknown> | undefined)?.agentId as string | undefined) ??
135
+ DEFAULT_AGENT_ID
136
+ const defaultKey = `agent:${agentId}:main`
137
+
138
+ api.registerGatewayMethod('clawly.session.sanitize', async ({params, respond}) => {
139
+ const dryRun = params.dryRun === true
140
+ const sessionKey = typeof params.sessionKey === 'string' ? params.sessionKey : defaultKey
141
+
142
+ // Only sanitize the main webchat session — non-main sessions (e.g. telegram/discord)
143
+ // legitimately use lastTo/lastAccountId/lastThreadId for routing.
144
+ if (sessionKey !== defaultKey) {
145
+ respond(true, {
146
+ ok: true,
147
+ ...(dryRun ? {} : {repaired: false}),
148
+ detail: `Skipped — sanitize only applies to "${defaultKey}"`,
149
+ })
150
+ return
151
+ }
152
+
153
+ const stateDir = api.runtime.state.resolveStateDir()
154
+ if (!stateDir) {
155
+ respond(true, {
156
+ ok: false,
157
+ ...(dryRun ? {} : {repaired: false}),
158
+ detail: 'Cannot resolve state dir',
159
+ })
160
+ return
161
+ }
162
+
163
+ const storePath = path.join(stateDir, 'agents', agentId, 'sessions', 'sessions.json')
164
+ let store: SessionsStore | null
165
+ try {
166
+ store = readSessionsStore(storePath)
167
+ } catch (err) {
168
+ const msg = err instanceof Error ? err.message : String(err)
169
+ respond(true, {
170
+ // dryRun: treat corrupt as "can't check, assume clean" — avoids
171
+ // showing a misleading Fix button for an unfixable environment error.
172
+ ...(dryRun ? {ok: true} : {ok: false, repaired: false}),
173
+ detail: `Sessions store unreadable or malformed: ${msg}`,
174
+ })
175
+ return
176
+ }
177
+ if (!store) {
178
+ respond(true, {
179
+ ok: true,
180
+ ...(dryRun ? {} : {repaired: false}),
181
+ detail: 'Sessions store not found — nothing to sanitize',
182
+ })
183
+ return
184
+ }
185
+
186
+ const entry = store.sessions?.[sessionKey] as Record<string, unknown> | undefined
187
+ if (!entry) {
188
+ respond(true, {
189
+ ok: true,
190
+ ...(dryRun ? {} : {repaired: false}),
191
+ detail: `Session "${sessionKey}" not found — nothing to sanitize`,
192
+ })
193
+ return
194
+ }
195
+
196
+ const issues = detectIssues(entry)
197
+ if (issues.length === 0) {
198
+ respond(true, {
199
+ ok: true,
200
+ ...(dryRun ? {} : {repaired: false}),
201
+ detail: 'Session delivery context is clean',
202
+ })
203
+ return
204
+ }
205
+
206
+ if (dryRun) {
207
+ respond(true, {ok: false, issues, detail: `Stale fields: ${issues.join(', ')}`})
208
+ return
209
+ }
210
+
211
+ repairEntry(entry)
212
+
213
+ try {
214
+ writeSessionsStore(storePath, store)
215
+ api.logger.info(`session.sanitize: cleared stale delivery fields — ${issues.join(', ')}`)
216
+ respond(true, {ok: true, repaired: true, issues, detail: `Cleared: ${issues.join(', ')}`})
217
+ } catch (err) {
218
+ const msg = err instanceof Error ? err.message : String(err)
219
+ api.logger.error(`session.sanitize: write failed — ${msg}`)
220
+ respond(true, {ok: false, repaired: false, detail: `Write failed: ${msg}`})
221
+ }
222
+ })
223
+
224
+ api.logger.info('session-sanitize: registered clawly.session.sanitize')
225
+ }
226
+
227
+ /**
228
+ * Standalone auto-sanitize — reads, checks, writes. Called from setupConfig on startup.
229
+ * Best-effort: logs warnings on failure, never throws.
230
+ */
231
+ export function autoSanitizeSession(api: PluginApi): void {
232
+ const stateDir = api.runtime.state.resolveStateDir()
233
+ if (!stateDir) return
234
+
235
+ const agentId =
236
+ (api.pluginConfig as Record<string, unknown> | undefined)?.agentId ?? DEFAULT_AGENT_ID
237
+ const storePath = path.join(stateDir, 'agents', String(agentId), 'sessions', 'sessions.json')
238
+ let store: SessionsStore | null
239
+ try {
240
+ store = readSessionsStore(storePath)
241
+ } catch (err) {
242
+ api.logger.warn(
243
+ `session-sanitize (auto): cannot read sessions store — ${(err as Error).message}`,
244
+ )
245
+ return
246
+ }
247
+ if (!store) return
248
+
249
+ const sessionKey = mainSessionKey(api)
250
+ const entry = store.sessions?.[sessionKey] as Record<string, unknown> | undefined
251
+ if (!entry) return
252
+
253
+ const issues = detectIssues(entry)
254
+ if (issues.length === 0) return
255
+
256
+ repairEntry(entry)
257
+ try {
258
+ writeSessionsStore(storePath, store)
259
+ api.logger.info(`session-sanitize (auto): cleared stale fields — ${issues.join(', ')}`)
260
+ } catch (err) {
261
+ api.logger.warn(`session-sanitize (auto): write failed — ${(err as Error).message}`)
262
+ }
263
+ }
@@ -0,0 +1,156 @@
1
+ import {describe, expect, test} from 'bun:test'
2
+ import {stripMarkdown} from './stripMarkdown'
3
+
4
+ describe('stripMarkdown', () => {
5
+ test('strips bold (**text**)', () => {
6
+ expect(stripMarkdown('Hello **world**!')).toBe('Hello world!')
7
+ })
8
+
9
+ test('strips italic (*text*)', () => {
10
+ expect(stripMarkdown('Hello *world*!')).toBe('Hello world!')
11
+ })
12
+
13
+ test('strips bold+italic (***text***)', () => {
14
+ expect(stripMarkdown('Hello ***world***!')).toBe('Hello world!')
15
+ })
16
+
17
+ test('strips strikethrough (~~text~~)', () => {
18
+ expect(stripMarkdown('Hello ~~world~~!')).toBe('Hello world!')
19
+ })
20
+
21
+ test('strips inline code (`text`)', () => {
22
+ expect(stripMarkdown('Run `npm install` to start')).toBe('Run npm install to start')
23
+ })
24
+
25
+ test('strips code block fences, keeps content', () => {
26
+ expect(stripMarkdown('Before ```const x = 1``` after')).toBe('Before const x = 1 after')
27
+ })
28
+
29
+ test('strips multiline code block fences with language tag', () => {
30
+ expect(stripMarkdown('Before\n```js\nconst x = 1\n```\nafter')).toBe(
31
+ 'Before\nconst x = 1\n\nafter',
32
+ )
33
+ })
34
+
35
+ test('strips multiline code block fences without language tag', () => {
36
+ expect(stripMarkdown('Before\n```\nconst x = 1\n```\nafter')).toBe(
37
+ 'Before\nconst x = 1\n\nafter',
38
+ )
39
+ })
40
+
41
+ test('strips links [text](url) → text', () => {
42
+ expect(stripMarkdown('Visit [our site](https://example.com) now')).toBe('Visit our site now')
43
+ })
44
+
45
+ test('strips links with parenthesized URLs', () => {
46
+ expect(
47
+ stripMarkdown('See [Function](https://en.wikipedia.org/wiki/Function_(mathematics)) here'),
48
+ ).toBe('See Function here')
49
+ })
50
+
51
+ test('strips images ![alt](url) → alt', () => {
52
+ expect(stripMarkdown('See ![photo](https://example.com/img.png) here')).toBe('See photo here')
53
+ })
54
+
55
+ test('strips heading markers', () => {
56
+ expect(stripMarkdown('# Hello')).toBe('Hello')
57
+ expect(stripMarkdown('## Subheading')).toBe('Subheading')
58
+ expect(stripMarkdown('### Deep heading')).toBe('Deep heading')
59
+ })
60
+
61
+ test('strips heading markers in multiline text', () => {
62
+ expect(stripMarkdown('Line one\n## Heading')).toBe('Line one\nHeading')
63
+ })
64
+
65
+ test('strips blockquote markers', () => {
66
+ expect(stripMarkdown('> This is a quote')).toBe('This is a quote')
67
+ })
68
+
69
+ test('strips nested blockquotes', () => {
70
+ expect(stripMarkdown('> > Nested content')).toBe('Nested content')
71
+ expect(stripMarkdown('> > > Deep nested')).toBe('Deep nested')
72
+ })
73
+
74
+ test('strips blockquotes without space', () => {
75
+ expect(stripMarkdown('>text')).toBe('text')
76
+ })
77
+
78
+ test('strips blockquoted headings and lists', () => {
79
+ expect(stripMarkdown('> ## Heading')).toBe('Heading')
80
+ expect(stripMarkdown('> - Item')).toBe('Item')
81
+ expect(stripMarkdown('> 1. First')).toBe('First')
82
+ })
83
+
84
+ test('strips unordered list markers', () => {
85
+ expect(stripMarkdown('- Item one')).toBe('Item one')
86
+ expect(stripMarkdown('* Item two')).toBe('Item two')
87
+ expect(stripMarkdown('+ Item three')).toBe('Item three')
88
+ })
89
+
90
+ test('strips ordered list markers', () => {
91
+ expect(stripMarkdown('1. First item')).toBe('First item')
92
+ expect(stripMarkdown('2. Second item')).toBe('Second item')
93
+ expect(stripMarkdown('10. Tenth item')).toBe('Tenth item')
94
+ })
95
+
96
+ test('strips horizontal rules', () => {
97
+ expect(stripMarkdown('Before\n---\nAfter')).toBe('Before\n\nAfter')
98
+ expect(stripMarkdown('Before\n***\nAfter')).toBe('Before\n\nAfter')
99
+ expect(stripMarkdown('Before\n___\nAfter')).toBe('Before\n\nAfter')
100
+ })
101
+
102
+ test('handles multiple markdown features combined', () => {
103
+ expect(stripMarkdown('**Hey!** Check [this](https://x.com) out — `code` here')).toBe(
104
+ 'Hey! Check this out — code here',
105
+ )
106
+ })
107
+
108
+ test('leaves plain text unchanged', () => {
109
+ expect(stripMarkdown('Just a normal message')).toBe('Just a normal message')
110
+ })
111
+
112
+ test('handles empty string', () => {
113
+ expect(stripMarkdown('')).toBe('')
114
+ })
115
+
116
+ test('collapses extra spaces from stripping', () => {
117
+ expect(stripMarkdown('Hello **world** !')).toBe('Hello world !')
118
+ })
119
+
120
+ test('preserves snake_case identifiers', () => {
121
+ expect(stripMarkdown('Use some_variable_name in your code')).toBe(
122
+ 'Use some_variable_name in your code',
123
+ )
124
+ })
125
+
126
+ test('preserves underscores in technical text', () => {
127
+ expect(stripMarkdown('Set __proto__ and __name__ carefully')).toBe(
128
+ 'Set __proto__ and __name__ carefully',
129
+ )
130
+ })
131
+
132
+ test('handles multiline LLM-style output', () => {
133
+ const input = '**Summary:**\n1. First point\n2. Second point\n> Note: be careful'
134
+ expect(stripMarkdown(input)).toBe('Summary:\nFirst point\nSecond point\nNote: be careful')
135
+ })
136
+
137
+ test('does not match bold across line boundaries', () => {
138
+ const input = '* Item one\n* Item two'
139
+ expect(stripMarkdown(input)).toBe('Item one\nItem two')
140
+ })
141
+
142
+ test('strips * list marker with inline emphasis', () => {
143
+ expect(stripMarkdown('* Use *caution* here')).toBe('Use caution here')
144
+ })
145
+
146
+ test('preserves asterisks in math/technical expressions', () => {
147
+ expect(stripMarkdown('Calculate 2*3*4 for the result')).toBe('Calculate 2*3*4 for the result')
148
+ expect(stripMarkdown('Calculate 2 * 3 * 4 for the result')).toBe(
149
+ 'Calculate 2 * 3 * 4 for the result',
150
+ )
151
+ })
152
+
153
+ test('preserves content from code-only input', () => {
154
+ expect(stripMarkdown('```const x = 1```')).toBe('const x = 1')
155
+ })
156
+ })
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Strip common Markdown formatting from text to produce plain-text
3
+ * suitable for push notification bodies.
4
+ *
5
+ * Handles: bold, italic, strikethrough, inline code, code blocks,
6
+ * links, images, headings, blockquotes, list markers, and horizontal rules.
7
+ */
8
+ export function stripMarkdown(text: string): string {
9
+ return (
10
+ text
11
+ // Code blocks — strip fences (and optional language tag), keep content
12
+ .replace(/```(?:\w*\n)?([\s\S]*?)```/g, '$1')
13
+ // Images ![alt](url) → alt (supports one level of balanced parens in URL)
14
+ .replace(/!\[([^\]]*)\]\([^()]*(?:\([^()]*\))*[^()]*\)/g, '$1')
15
+ // Links [text](url) → text (supports one level of balanced parens in URL)
16
+ .replace(/\[([^\]]*)\]\([^()]*(?:\([^()]*\))*[^()]*\)/g, '$1')
17
+ // ── Line-start structural markers (before inline formatting) ──
18
+ // Must run before bold/italic so that `* *text*` strips the list
19
+ // marker first, leaving `*text*` for the emphasis regex.
20
+ // Blockquote markers first — so `> ## Heading` and `> - Item` are
21
+ // unquoted before heading/list regexes run. Handles nested (`> > text`)
22
+ // and no-space (`>text`) variants in a single pass.
23
+ .replace(/(^|\n)(>\s*)+/g, '$1')
24
+ // Heading markers (# at start of line)
25
+ .replace(/(^|\n)#{1,6}\s+/g, '$1')
26
+ // Ordered list markers (1. 2. etc at start of line)
27
+ .replace(/(^|\n)\d+\.\s+/g, '$1')
28
+ // Unordered list markers (- * +) at start of line
29
+ .replace(/(^|\n)[*+-]\s+/g, '$1')
30
+ // ── Inline formatting ──
31
+ // Bold/italic with * only (**text**, *text*, ***text***)
32
+ // Intentionally skips _underscore_ variants to avoid corrupting
33
+ // snake_case identifiers and __dunder__ names common in LLM output.
34
+ // Uses CommonMark-style flanking rules: opening * must be followed by
35
+ // non-whitespace, closing * must be preceded by non-whitespace. This
36
+ // rejects spaced math like `2 * 3 * 4` while matching `*italic*`.
37
+ .replace(/(?<!\w)\*{1,3}(?!\s)([^*\n]+?)(?<!\s)\*{1,3}(?!\w)/g, '$1')
38
+ // Strikethrough ~~text~~
39
+ .replace(/~~([^~]+?)~~/g, '$1')
40
+ // Inline code `text`
41
+ .replace(/`([^`]+?)`/g, '$1')
42
+ // Horizontal rules (--- *** ___) on their own line
43
+ .replace(/(^|\n)([-*_]){3,}(\n|$)/g, '$1$3')
44
+ // Collapse multiple spaces and normalize whitespace
45
+ .replace(/ {2,}/g, ' ')
46
+ .trim()
47
+ )
48
+ }
@@ -13,6 +13,10 @@ import path from 'node:path'
13
13
  import type {PluginApi} from './index'
14
14
 
15
15
  export const PROVIDER_NAME = 'clawly-model-gateway'
16
+ type ModelGatewayConfigInputs = {
17
+ modelGatewayBaseUrl?: string
18
+ modelGatewayToken?: string
19
+ }
16
20
 
17
21
  /** Additional models available through the model gateway (beyond env-configured defaults). */
18
22
  export const EXTRA_GATEWAY_MODELS: Array<{
@@ -83,7 +87,17 @@ export function writeOpenclawConfig(configPath: string, config: Record<string, u
83
87
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n')
84
88
  }
85
89
 
86
- export function patchModelGateway(config: Record<string, unknown>, api: PluginApi): boolean {
90
+ export function patchModelGateway(
91
+ config: Record<string, unknown>,
92
+ api: PluginApi,
93
+ inputs?: ModelGatewayConfigInputs,
94
+ ): boolean {
95
+ const cfg =
96
+ inputs ??
97
+ (api.pluginConfig as Record<string, unknown> | undefined as
98
+ | ModelGatewayConfigInputs
99
+ | undefined)
100
+
87
101
  // If provider already exists, check if extra models or aliases need updating.
88
102
  // This runs before the credentials check because provisioned sprites have
89
103
  // credentials in openclaw.json directly, not in pluginConfig.
@@ -116,7 +130,6 @@ export function patchModelGateway(config: Record<string, unknown>, api: PluginAp
116
130
 
117
131
  // Backfill pluginConfig from existing provider credentials (legacy sprites
118
132
  // provisioned before plugin config was written during configure phase).
119
- const cfg = api.pluginConfig as Record<string, unknown> | undefined
120
133
  if (
121
134
  existingProvider.baseUrl &&
122
135
  existingProvider.apiKey &&
@@ -149,7 +162,6 @@ export function patchModelGateway(config: Record<string, unknown>, api: PluginAp
149
162
  }
150
163
 
151
164
  // No existing provider — need pluginConfig credentials to create one
152
- const cfg = api.pluginConfig as Record<string, unknown> | undefined
153
165
  const baseUrl =
154
166
  typeof cfg?.modelGatewayBaseUrl === 'string' ? cfg.modelGatewayBaseUrl.replace(/\/$/, '') : ''
155
167
  const token = typeof cfg?.modelGatewayToken === 'string' ? cfg.modelGatewayToken : ''
@@ -212,7 +224,13 @@ export function patchModelGateway(config: Record<string, unknown>, api: PluginAp
212
224
  return true
213
225
  }
214
226
 
215
- /** Standalone wrapper — reads config, patches, writes. Used by tests. */
227
+ /**
228
+ * Standalone wrapper — reads config, patches, writes. Used by tests.
229
+ *
230
+ * This helper intentionally does not apply the file-backed pluginConfig fallback
231
+ * from setupConfig(); production startup should go through the full runtime
232
+ * reconcile path there.
233
+ */
216
234
  export function setupModelGateway(api: PluginApi): void {
217
235
  const stateDir = api.runtime.state.resolveStateDir()
218
236
  if (!stateDir) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2en/clawly-plugins",
3
- "version": "1.26.0",
3
+ "version": "1.26.1-beta.1",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "repository": {